Compare commits

..
Author SHA1 Message Date
l0ng-ai dd48f79265 fix(agents): preserve upward scrolling during streaming replies 2026-11-10 15:59:13 +08:00
142 changed files with 2321 additions and 5704 deletions

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"plugins/themes.md",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
"fixtures",
+1 -1
View File
@@ -21,7 +21,7 @@ Visual style: dark, low-contrast greys, one cool accent
- `crates/ide-host` — the `openidea-server` binary for remote development and the
`openidea-plugin` CLI (plugin starter templates live in its `templates.rs`).
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins.
Plugin docs: `plugins/README.md`, `plugins/themes.md`.
Plugin docs: `plugins/README.md`, `docs/plugins/themes.md`.
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
Generated
-2
View File
@@ -4232,10 +4232,8 @@ name = "ide-build"
version = "0.1.40"
dependencies = [
"blake3",
"chardetng",
"dirs 6.0.0",
"dunce",
"encoding_rs",
"ide-core",
"serde",
"serde_json",
+1 -1
View File
@@ -64,7 +64,7 @@ the [official plugins](plugins/official). The plugin API is still a preview.
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
- WeChat group — scan to join:
<img src=".github/assets/wechat-group.png" alt="WeChat group QR code" width="200">
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
### Contributors
+2 -6
View File
@@ -262,7 +262,7 @@ END OF TERMS AND CONDITIONS
The Solarized palettes in `src/styles/tokens.css` are adapted from
[Ethan Schoonover’s Solarized](https://github.com/altercation/solarized/tree/62f656a02f93c5190a8753159e34b385588d5ff3).
MIT license:
See [theme sources and mapping](docs/themes.md). MIT license:
```text
Copyright (c) 2011 Ethan Schoonover
@@ -290,11 +290,7 @@ THE SOFTWARE.
The One palettes and semantic syntax mapping in `src/styles/tokens.css` are
adapted from GitHub’s Atom One syntax and UI themes.
Source revisions:
[one-light-syntax d845790](https://github.com/atom/one-light-syntax/tree/d84579027410c576086dfca14d934c4bd74b0438),
[one-dark-syntax 9c96f44](https://github.com/atom/one-dark-syntax/tree/9c96f4454362267ac45322063e193ccf9d2debb1),
[one-light-ui a27bce3](https://github.com/atom/one-light-ui/tree/a27bce35088ecd323e44f5c2510bcf2d4e9efcf8),
[one-dark-ui 18c2143](https://github.com/atom/one-dark-ui/tree/18c2143d41e17b337fff6dc7024562c4d999be17).
Source revisions are listed in [theme sources and mapping](docs/themes.md).
Both syntax themes use the following MIT notice:
```text
+2 -82
View File
@@ -345,8 +345,6 @@ struct Task {
/// Settings already sent this session (`(option, value)`), so a refusal is not retried.
config_sent: Mutex<HashSet<(String, String)>>,
dirty: Mutex<BTreeSet<(Session, usize)>>,
/// How much of each session's streaming message was last sent, so the next flush sends the rest.
streamed: Mutex<HashMap<Session, Sent>>,
flush_scheduled: AtomicBool,
stderr: Mutex<VecDeque<String>>,
/// Set by `delete`; held while saving.
@@ -854,7 +852,6 @@ impl AgentManager {
s.task.updated_ms = now_ms();
first
};
task.streamed.lock().clear();
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: 0 });
task.emit_task();
@@ -880,7 +877,6 @@ impl AgentManager {
s.task.updated_ms = now_ms();
first
};
task.streamed.lock().clear();
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
task.emit_task();
@@ -1133,7 +1129,6 @@ impl Task {
idle_epoch: AtomicU64::new(0),
config_sent: Mutex::new(HashSet::new()),
dirty: Mutex::new(BTreeSet::new()),
streamed: Mutex::new(HashMap::new()),
flush_scheduled: AtomicBool::new(false),
stderr: Mutex::new(VecDeque::new()),
deleted: Mutex::new(false),
@@ -1255,12 +1250,11 @@ impl Task {
let indices = std::mem::take(&mut *self.dirty.lock());
let events: Vec<AgentEvent> = {
let s = self.state.lock();
let mut streamed = self.streamed.lock();
indices
.into_iter()
.filter_map(|(subagent, i)| {
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?;
Some(entry_event(&self.id, &mut streamed, subagent, i, entry))
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?.clone();
Some(AgentEvent::Entry { task: self.id.clone(), subagent, index: i as u32, entry })
})
.collect()
};
@@ -2322,77 +2316,3 @@ fn stdio_mcp_servers(executable: Option<&std::path::Path>, supported: bool, root
let command = ide_mcp::stdio_command(executable, root);
vec![json!({ "name": "OpenIDEA", "command": command[0], "args": command[1..], "env": [] })]
}
/// What was sent of a streaming message: its first `bytes` (`utf16` UTF-16 units), which
/// ended with `tail`. A message only grows while it streams, so its tail tells a message
/// that went on from one that was replaced without comparing (or keeping) all of it.
struct Sent {
index: usize,
bytes: usize,
utf16: usize,
tail: String,
}
const SENT_TAIL: usize = 64;
impl Sent {
fn new(index: usize, text: &str) -> Self {
Self::after(index, text, 0, 0)
}
/// `text` as sent, of which the first `bytes` (`utf16` units) were sent before.
fn after(index: usize, text: &str, bytes: usize, utf16: usize) -> Self {
let mut start = text.len().saturating_sub(SENT_TAIL);
while !text.is_char_boundary(start) {
start -= 1;
}
let utf16 = utf16 + text[bytes..].encode_utf16().count();
Self { index, bytes: text.len(), utf16, tail: text[start..].to_owned() }
}
/// Whether `text` at `index` is this message, gone on.
fn extended_by(&self, index: usize, text: &str) -> bool {
self.index == index
&& text.len() > self.bytes
&& text.is_char_boundary(self.bytes)
&& text[..self.bytes].ends_with(&self.tail)
}
}
/// The event that brings the UI's copy of entry `i` up to date: only the new text when
/// it extends the message last sent for the session, else the whole entry.
fn entry_event(
task: &str,
streamed: &mut HashMap<Session, Sent>,
subagent: Session,
i: usize,
entry: &AgentEntry,
) -> AgentEvent {
let text = match entry {
AgentEntry::Agent { text } | AgentEntry::Thought { text } => Some(text),
_ => None,
};
let Some(text) = text else {
if streamed.get(&subagent).is_some_and(|sent| sent.index == i) {
streamed.remove(&subagent);
}
return AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() };
};
match streamed.get(&subagent).filter(|sent| sent.extended_by(i, text)) {
Some(sent) => {
let (bytes, from) = (sent.bytes, sent.utf16);
streamed.insert(subagent.clone(), Sent::after(i, text, bytes, from));
AgentEvent::Append {
task: task.to_owned(),
subagent,
index: i as u32,
from: from as u32,
text: text[bytes..].to_owned(),
}
}
None => {
streamed.insert(subagent.clone(), Sent::new(i, text));
AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() }
}
}
}
-9
View File
@@ -518,15 +518,6 @@ pub enum AgentEvent {
index: u32,
entry: AgentEntry,
},
/// Text was appended to entry `index` (an agent message or a thought): it read the first
/// `from` UTF-16 units of its text when the previous event for it was sent.
Append {
task: String,
subagent: Option<String>,
index: u32,
from: u32,
text: String,
},
/// An agent reported the usage limits of its account (shared by every task of `provider`).
Limits {
provider: String,
-36
View File
@@ -2532,39 +2532,3 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
}
}
}
#[test]
fn streamed_text_is_sent_as_appends() {
let mut streamed = HashMap::new();
let agent = |t: &str| AgentEntry::Agent { text: t.into() };
let first = entry_event("t", &mut streamed, None, 3, &agent("Hé"));
assert!(matches!(first, AgentEvent::Entry { index: 3, .. }), "the first sight of a message is whole");
let next = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there"));
assert!(
matches!(&next, AgentEvent::Append { subagent: None, index: 3, from: 2, text, .. } if text == " 👋 there"),
"{next:?}"
);
let sub = entry_event("t", &mut streamed, Some("s".into()), 3, &agent("Hé 👋 there!"));
assert!(matches!(sub, AgentEvent::Entry { .. }), "each session streams on its own");
let after = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there."));
assert!(
matches!(&after, AgentEvent::Append { from: 11, text, .. } if text == "."),
"from counts UTF-16 units: {after:?}"
);
let replaced = entry_event("t", &mut streamed, None, 3, &agent("Other"));
assert!(matches!(replaced, AgentEvent::Entry { .. }), "text that doesn't extend the sent text is sent whole");
let thought = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm…".into() });
assert!(matches!(thought, AgentEvent::Entry { index: 4, .. }), "a new message starts whole");
let tool = AgentEntry::Error { message: "x".into() };
entry_event("t", &mut streamed, None, 4, &tool);
let again = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm… ok".into() });
assert!(matches!(again, AgentEvent::Entry { .. }), "an entry replaced by another kind forgets its text");
let long = "é".repeat(5000);
entry_event("t", &mut streamed, None, 5, &agent(&long));
assert!(streamed[&None].tail.len() <= 64, "only the end of what was sent is kept");
let on = entry_event("t", &mut streamed, None, 5, &agent(&format!("{long}!")));
assert!(matches!(&on, AgentEvent::Append { from: 5000, text, .. } if text == "!"), "{on:?}");
let rewritten = entry_event("t", &mut streamed, None, 5, &agent(&format!("{}x!??", "é".repeat(4999))));
assert!(matches!(rewritten, AgentEvent::Entry { .. }), "a rewritten end is sent whole");
}
-2
View File
@@ -7,9 +7,7 @@ license.workspace = true
[dependencies]
blake3.workspace = true
chardetng.workspace = true
dunce.workspace = true
encoding_rs.workspace = true
ide-core.workspace = true
zip.workspace = true
serde.workspace = true
+5 -23
View File
@@ -499,7 +499,7 @@ pub fn user_settings_file(root: &Path, settings: &ide_core::settings::MavenSetti
/// IDEA lists them): checked by default when `<activeProfiles>` names them or they are
/// `activeByDefault`.
pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
let Some(root) = xml::read_text(settings).as_deref().and_then(xml::parse) else {
let Some(root) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
return Vec::new();
};
let active: Vec<String> =
@@ -519,7 +519,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
/// `<localRepository>` of a settings file, when it names one: the repository a build
/// with `-s <file>` resolves into, which is not necessarily the user's default.
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
let text = xml::read_text(settings)?;
let text = std::fs::read_to_string(settings).ok()?;
let root = xml::parse(&text)?;
let repo = root.text_of("localRepository")?.trim().to_owned();
if repo.is_empty() {
@@ -545,7 +545,7 @@ impl Resolver {
fn pom(&mut self, path: &Path) -> Option<RawPom> {
self.poms
.entry(path.to_path_buf())
.or_insert_with(|| xml::read_text(path).as_deref().and_then(parse_pom))
.or_insert_with(|| std::fs::read_to_string(path).ok().and_then(|t| parse_pom(&t)))
.clone()
}
@@ -736,7 +736,7 @@ pub(crate) fn launch_inputs(
return None;
}
out.push(path.clone());
let text = xml::read_text(&path)?;
let text = std::fs::read_to_string(&path).ok()?;
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
let raw = resolver.pom(&path)?;
let Some(parent) = raw.parent else { break };
@@ -1640,7 +1640,7 @@ fn parent_chain(path: &Path, text: &str, local_repo: Option<PathBuf>) -> Vec<(Pa
let Some(parent) = pom.and_then(|p| p.parent) else { return chain };
let Some(pp) = resolver.parent_path(&dir, &parent) else { return chain };
let pp = dunce::canonicalize(&pp).unwrap_or(pp);
let Some(next) = xml::read_text(&pp) else { return chain };
let Ok(next) = std::fs::read_to_string(&pp) else { return chain };
pom = resolver.pom(&pp);
dir = pp.parent().unwrap_or(Path::new("")).to_path_buf();
chain.push((pp, next));
@@ -2297,24 +2297,6 @@ mod tests {
assert!(!is_maven_project(&root.join("c")));
}
#[test]
fn non_utf8_pom_with_declared_encoding_is_a_build() {
// Issue #2439: a GBK `pom.xml` is not valid UTF-8; it must still load
// instead of vanishing as "No maven project".
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let (bytes, _, _) = encoding_rs::GBK.encode(
"<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><groupId>g</groupId>\
<artifactId>应用</artifactId><version>1</version><name>演示</name></project>",
);
std::fs::write(root.join("pom.xml"), bytes.as_ref()).unwrap();
let model = load_with_repo(root, None);
assert_eq!(model.projects.len(), 1);
assert_eq!(model.projects[0].artifact_id, "应用");
assert_eq!(model.projects[0].display_name, "演示");
assert!(is_maven_project(root));
}
#[test]
fn aggregate_pom_in_src_is_a_build() {
// Issue #1001: `src/pom.xml` aggregating modules under `src/` is recognized,
-66
View File
@@ -3,57 +3,6 @@
//! skipped. Namespace prefixes are dropped (`<x:project>` → `project`).
//! Elements keep their byte offsets in the source, for edits ([`crate::pom_edit`]).
use encoding_rs::Encoding;
use std::path::Path;
/// Reads an XML file, honoring its `encoding="…"` declaration: a GBK POM
/// (issue #2439) is not valid UTF-8, and `read_to_string` would drop the
/// whole project as "No maven project" while CLI Maven reads it fine.
pub fn read_text(path: &Path) -> Option<String> {
std::fs::read(path).ok().map(|bytes| decode_bytes(&bytes))
}
/// Decodes XML bytes: BOM first, then the `encoding` declaration (ASCII in
/// every single-byte encoding, so the head scans before decoding), then
/// UTF-8, then a chardetng guess.
pub fn decode_bytes(bytes: &[u8]) -> String {
if let Some((enc, len)) = Encoding::for_bom(bytes) {
let (text, _) = enc.decode_without_bom_handling(&bytes[len..]);
return text.into_owned();
}
if let Some(enc) = declared_encoding(bytes) {
let (text, _) = enc.decode_without_bom_handling(bytes);
return text.into_owned();
}
if let Ok(text) = std::str::from_utf8(bytes) {
return text.to_owned();
}
let mut detector = chardetng::EncodingDetector::new();
detector.feed(bytes, true);
let (text, _) = detector.guess(None, true).decode_without_bom_handling(bytes);
text.into_owned()
}
fn declared_encoding(bytes: &[u8]) -> Option<&'static Encoding> {
let head = std::str::from_utf8(bytes.get(..bytes.len().min(1024))?).ok()?;
let mut rest = head.split("?>").next().unwrap_or(head);
while let Some(i) = rest.find("encoding") {
rest = &rest[i + "encoding".len()..];
let Some(value) = rest.trim_start().strip_prefix('=') else { continue };
let value = value.trim_start();
let (&quote, after) = value.as_bytes().split_first()?;
if quote != b'"' && quote != b'\'' {
continue;
}
let after = std::str::from_utf8(after).ok()?;
let end = after.find(quote as char)?;
if let Some(enc) = Encoding::for_label(after[..end].trim().as_bytes()) {
return Some(enc);
}
}
None
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Element {
pub name: String,
@@ -261,19 +210,4 @@ mod tests {
assert!(parse("").is_none());
assert!(parse("just text").is_none());
}
#[test]
fn decodes_the_declared_encoding() {
// Issue #2439: a GBK POM is not valid UTF-8; decoding must honor the declaration.
let (bytes, _, _) =
encoding_rs::GBK.encode("<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><name>中文</name></project>");
let text = decode_bytes(&bytes);
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("中文"));
// An unknown label falls back to UTF-8 rather than mis-decoding.
let text = decode_bytes(b"<?xml version=\"1.0\" encoding=\"X-UNKNOWN\"?>\n<project><name>ok</name></project>");
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("ok"));
// No declaration: plain UTF-8 passes through untouched.
let text = decode_bytes("<project><name>plain</name></project>".as_bytes());
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("plain"));
}
}
+5 -131
View File
@@ -59,16 +59,16 @@ pub struct Settings {
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
/// Settings › Agents.
/// Tools › Agents › Chat.
pub agents: AgentSettings,
/// Agents › MCP Server (global only).
/// Tools › MCP Server (global only).
pub mcp_server: McpServerSettings,
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
}
/// Settings › Agents: what the conversation shows and how messages are sent.
/// What the meters in an agent task's header show.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AgentSettings {
@@ -80,125 +80,15 @@ pub struct AgentSettings {
pub show_usage_limits: bool,
/// What the session has cost so far, as the agent reports it.
pub show_cost: bool,
/// A finished turn's work folds into one line under its message, leaving the reply.
pub fold_turns: bool,
/// Streaming text is revealed at a steady pace rather than chunk by chunk.
pub smooth_text: bool,
#[serde(deserialize_with = "or_default")]
#[specta(type = ThinkingDisplay)]
pub thinking: ThinkingDisplay,
#[serde(deserialize_with = "or_default")]
#[specta(type = ToolCallDisplay)]
pub tool_calls: ToolCallDisplay,
/// A tool call's output (a command's, a diff) is shown without opening it.
pub expand_tool_output: bool,
#[serde(deserialize_with = "or_default")]
#[specta(type = SendKey)]
pub send_with: SendKey,
/// What sending does while the agent works: queue the message for after the turn, or
/// send it into the turn.
#[serde(deserialize_with = "or_default")]
#[specta(type = WhileRunning)]
pub while_running: WhileRunning,
/// Settings new tasks of an agent start with: agent id → option id → value.
#[serde(deserialize_with = "agent_defaults")]
#[specta(type = BTreeMap<String, BTreeMap<String, String>>)]
pub defaults: BTreeMap<String, BTreeMap<String, String>>,
}
impl Default for AgentSettings {
fn default() -> Self {
Self {
show_context_usage: true,
show_work_time: true,
show_usage_limits: true,
show_cost: false,
fold_turns: true,
smooth_text: true,
thinking: ThinkingDisplay::Auto,
tool_calls: ToolCallDisplay::Grouped,
expand_tool_output: false,
send_with: SendKey::Enter,
while_running: WhileRunning::Queue,
defaults: BTreeMap::new(),
}
Self { show_context_usage: true, show_work_time: true, show_usage_limits: true, show_cost: false }
}
}
/// A hand-edited value these settings don't know reads as the default, rather than
/// failing the whole settings file.
fn or_default<'de, D, T>(d: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::de::DeserializeOwned + Default,
{
Ok(serde_json::from_value(serde_json::Value::deserialize(d)?).unwrap_or_default())
}
/// [`AgentSettings::defaults`], keeping only the text values of each agent's table.
fn agent_defaults<'de, D>(d: D) -> Result<BTreeMap<String, BTreeMap<String, String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let serde_json::Value::Object(agents) = serde_json::Value::deserialize(d)? else { return Ok(BTreeMap::new()) };
Ok(agents
.into_iter()
.filter_map(|(agent, options)| match options {
serde_json::Value::Object(options) => Some((
agent,
options
.into_iter()
.filter_map(|(k, v)| match v {
serde_json::Value::String(v) => Some((k, v)),
_ => None,
})
.collect(),
)),
_ => None,
})
.collect())
}
/// How the agent's reasoning shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ThinkingDisplay {
/// Open while it streams, folded once it ends.
#[default]
Auto,
Expanded,
Collapsed,
}
/// How tool calls between messages show.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ToolCallDisplay {
/// A run of calls is one line saying what they did ("Read 4 files · Edited 2 files").
#[default]
Grouped,
/// Each call on its own line.
Each,
}
/// The key that sends a message; the other one starts a new line.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum SendKey {
#[default]
Enter,
ModEnter,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum WhileRunning {
#[default]
Queue,
Steer,
}
/// Agents › MCP Server: agents use the IDE's built-in tools.
/// Tools › MCP Server: agents use the IDE's built-in tools.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
@@ -1460,22 +1350,6 @@ mod tests {
assert_eq!(restored.agents, settings.agents);
}
#[test]
fn hand_edited_agent_values_it_does_not_know_keep_the_rest() {
let settings: Settings = serde_json::from_str(
r#"{"editor":{"fontSize":17},"agents":{"thinking":"open","sendWith":"modEnter",
"defaults":{"claude":{"model":"opus","effort":3},"codex":"x"}}}"#,
)
.unwrap();
assert_eq!(settings.editor.font_size, 17.0);
assert_eq!(settings.agents.thinking, ThinkingDisplay::Auto);
assert_eq!(settings.agents.send_with, SendKey::ModEnter);
assert_eq!(
settings.agents.defaults,
BTreeMap::from([("claude".into(), BTreeMap::from([("model".into(), "opus".into())]))])
);
}
#[test]
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
-18
View File
@@ -594,13 +594,6 @@ fn is_identifier(name: &str) -> bool {
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
}
/// sqlx's handshake flags for `sql_mode`: with either enabled it sends
/// `SET sql_mode=(SELECT CONCAT(...))`, a non-constant subquery that MySQL-protocol
/// servers like StarRocks reject (#2452), so the StarRocks preset turns both off.
fn mysql_mode_flags(vendor: &str) -> (bool, bool) {
if vendor == "starrocks" { (false, false) } else { (true, true) }
}
/// A MySQL `SET` value: numbers and keywords as they are, anything else quoted.
fn mysql_value(v: &str) -> String {
let keyword = ["ON", "OFF", "TRUE", "FALSE", "DEFAULT"].iter().any(|k| k.eq_ignore_ascii_case(v));
@@ -846,8 +839,6 @@ impl Pool {
if let Some(c) = &setup.collation {
opts = opts.collation(c);
}
let (pipes_as_concat, no_engine_substitution) = mysql_mode_flags(&source.vendor);
opts = opts.pipes_as_concat(pipes_as_concat).no_engine_substitution(no_engine_substitution);
if let Some(n) = setup.statement_cache {
opts = opts.statement_cache_capacity(n);
}
@@ -1474,15 +1465,6 @@ mod tests {
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
}
#[test]
fn starrocks_disables_the_non_constant_sql_mode_handshake() {
// MySQL and its other wire-compatible presets keep sqlx's defaults.
for vendor in ["", "mysql", "mariadb", "tidb"] {
assert_eq!(mysql_mode_flags(vendor), (true, true), "{vendor}");
}
assert_eq!(mysql_mode_flags("starrocks"), (false, false));
}
#[test]
fn sqlite_properties_become_pragmas() {
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
+1 -2
View File
@@ -270,7 +270,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
match scheme.to_ascii_lowercase().as_str() {
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" | "starrocks" => Some(DbDriver::Mysql),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
@@ -559,7 +559,6 @@ mod tests {
assert_eq!((u.host.as_str(), u.port, u.user.as_str()), ("::1", None, "root"));
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("starrocks://h:9030").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
let u = parse_url("oracle://scott:[email protected]:1521/ORCLPDB1").unwrap();
assert_eq!(u.driver, Some(DbDriver::Oracle));
@@ -208,7 +208,7 @@ authors = []
engines.openidea = "^1.0"
# One entry per theme. `editor-scheme` is optional: its tokens are merged over
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: plugins/themes.md.
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: docs/plugins/themes.md.
[[contributes.themes]]
path = "themes/__SLUG__.theme.json"
editor-scheme = "themes/__SLUG__.scheme.json"
+1 -2
View File
@@ -426,8 +426,7 @@ pub async fn vcs_log_details_batch(
let cancel = host.begin_project_read_request(window, request);
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
r.enable_log_index(&project.cache);
r.log_details_batch_cancellable(&revisions, &cancel)
revisions.iter().map(|id| r.log_details_cancellable(id, &cancel)).collect()
})
})
.await;
+1 -1
View File
@@ -105,7 +105,7 @@ fn requests(message: &Value) -> Vec<Value> {
async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Result<(StatusCode, Bytes), String> {
let not_running = || {
format!(
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Agents › MCP Server",
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Tools › MCP Server",
project.display()
)
};
+153 -371
View File
@@ -5,7 +5,6 @@ use crate::{ChangeKind, ChangedFile, Commit, LogOrder, LogQuery, Repo, RevisionC
use gix::ObjectId;
use gix::bstr::ByteSlice;
use ide_core::{CancellationToken, IdeError, IdeResult};
use redb::ReadableDatabase;
use std::collections::{HashMap, HashSet};
/// Log "Text or hash" filter: the message (substring or regex), or a hash prefix.
@@ -277,60 +276,44 @@ pub(crate) fn log(r: &Repo, q: &LogQuery, cancel: &CancellationToken) -> IdeResu
all.retain_until = None;
all.request = None;
all.stop_at = None;
let repo = r.gix()?;
let decorations = decorations(&repo)?;
let shallow_vec =
repo.shallow_commits().map_err(gx)?.map(|ids| ids.iter().copied().collect::<Vec<_>>()).unwrap_or_default();
let shallow: HashSet<ObjectId> = shallow_vec.iter().copied().collect();
let refs = r.log_refs()?;
let key = format!("{}:{refs:?}:{shallow_vec:?}", serde_json::to_string(&all).map_err(gx)?);
let shallow = r
.gix()?
.shallow_commits()
.map_err(gx)?
.map(|ids| ids.iter().copied().collect::<Vec<_>>())
.unwrap_or_default();
let key = format!("{}:{refs:?}:{shallow:?}", serde_json::to_string(&all).map_err(gx)?);
let cached = index.views.get(&key).cloned();
let view = if let Some(cached) = cached {
cached
} else {
// Immutable metadata is reused across roots, filters, merges and ref changes.
all.request = q.request;
let rows =
std::sync::Arc::new(crate::log_index::IndexedView::new(log_indexed_rows(r, &all, cancel, &mut index)?));
let commits =
std::sync::Arc::new(crate::log_index::IndexedView::new(log_indexed(r, &all, cancel, &mut index)?));
if index.views.len() >= 4 {
index.views.clear();
}
index.views.insert(key, rows.clone());
rows
};
let find_pos = |id: &str| {
ObjectId::from_hex(id.as_bytes())
.ok()
.and_then(|oid| view.positions.get(&oid).copied())
.or_else(|| view.rows.iter().position(|row| row.id.to_string().starts_with(id)))
index.views.insert(key, commits.clone());
commits
};
let start = q
.seek
.as_ref()
.and_then(|id| find_pos(id))
.and_then(|id| view.positions.get(id).copied())
.map(|i| i.saturating_sub(if q.limit == 0 { 50 } else { 50.min(q.limit as usize / 2) }))
.unwrap_or(q.skip as usize);
let retain = q.retain_until.as_ref().and_then(|id| find_pos(id)).map(|i| i + 1).unwrap_or(0);
let retain = q.retain_until.as_ref().and_then(|id| view.positions.get(id).copied()).map(|i| i + 1).unwrap_or(0);
let limit = if q.limit == 0 { usize::MAX } else { (q.limit as usize).max(retain.saturating_sub(start)) };
let total = view.rows.len() as u32;
let db = index.db().cloned();
let txn = db.as_ref().and_then(|db| db.begin_read().ok());
let table = txn.as_ref().and_then(|t| t.open_table(crate::log_index::COMMITS).ok());
let mut result = Vec::new();
for (i, row) in view.rows.iter().enumerate().skip(start).take(limit) {
for (i, c) in view.commits.iter().enumerate().skip(start).take(limit) {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let c = materialize_row(
&repo,
&mut index,
table.as_ref(),
row,
&shallow,
&decorations,
true,
Some((i as u32, total)),
)?;
let mut c = c.clone();
c.log_position = Some(i as u32);
c.log_total = Some(view.commits.len() as u32);
let stop = q.stop_at.as_ref() == Some(&c.id);
result.push(c);
if stop {
@@ -345,186 +328,12 @@ pub(crate) fn log(r: &Repo, q: &LogQuery, cancel: &CancellationToken) -> IdeResu
result
}
#[allow(clippy::too_many_arguments)]
fn materialize_row(
repo: &gix::Repository,
index: &mut crate::log_index::LogIndex,
table: Option<&redb::ReadOnlyTable<&str, &[u8]>>,
row: &crate::log_index::IndexedRow,
shallow: &HashSet<ObjectId>,
decorations: &HashMap<ObjectId, Vec<String>>,
summary_only: bool,
position: Option<(u32, u32)>,
) -> IdeResult<Commit> {
let record = index.get_with_table(repo, row.id, table)?;
let mut commit = (*record).clone();
let parents =
commit.parents.iter().map(|p| ObjectId::from_hex(p.as_bytes()).map_err(gx)).collect::<IdeResult<Vec<_>>>()?;
commit.graph_dashed = row.graph_parents.iter().map(|p| !parents.contains(p)).collect();
commit.dashed = if commit.graph_dashed.iter().any(|d| *d) { commit.graph_dashed.clone() } else { Vec::new() };
commit.graph_parents = Some(row.graph_parents.iter().map(ToString::to_string).collect());
if shallow.contains(&row.id) {
commit.graph_boundary_parents = commit.parents.clone();
}
commit.on_current_branch = Some(row.on_current_branch);
commit.refs = decorations.get(&row.id).cloned().unwrap_or_default();
commit.history_path = row.history_path.clone();
if let Some((pos, total)) = position {
commit.log_position = Some(pos);
commit.log_total = Some(total);
}
if !summary_only {
commit.message = index.message(repo, row.id)?;
commit.message_truncated = false;
}
Ok(commit)
}
fn update_head_reachable(
repo: &gix::Repository,
index: &mut crate::log_index::LogIndex,
cancel: &CancellationToken,
) -> IdeResult<()> {
let head = repo.head_id().ok().map(|id| id.detach());
let shallow_stamp =
repo.shallow_file().ok().and_then(|path| std::fs::metadata(path).ok()).and_then(|m| m.modified().ok());
let stamp = head.map(|id| (id, shallow_stamp));
if index.head == stamp {
return Ok(());
}
let mut additions = Vec::new();
let mut cursor = head;
let old_head = index.head;
if let (Some((old, old_shallow)), Some(_)) = (old_head, head)
&& old_shallow == shallow_stamp
{
while let Some(id) = cursor {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if id == old {
break;
}
let record = index.get(repo, id)?;
if record.parents.len() != 1 {
cursor = None;
break;
}
additions.push(id);
cursor = Some(ObjectId::from_hex(record.parents[0].as_bytes()).map_err(gx)?);
}
} else {
cursor = None;
}
if cursor.is_some() && cursor == old_head.map(|(id, _)| id) {
index.reachable.extend(additions);
index.head = stamp;
} else {
let mut reachable = HashSet::new();
if let Some(head) = head {
for item in repo.rev_walk([head]).all().map_err(gx)? {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
reachable.insert(item.map_err(gx)?.id);
}
}
index.reachable = reachable;
index.head = stamp;
}
Ok(())
}
fn log_indexed(
r: &Repo,
q: &LogQuery,
cancel: &CancellationToken,
index: &mut crate::log_index::LogIndex,
) -> IdeResult<Vec<Commit>> {
let repo = r.gix()?;
let shallow: HashSet<ObjectId> =
repo.shallow_commits().map_err(gx)?.map(|ids| ids.iter().copied().collect()).unwrap_or_default();
let decorations = decorations(&repo)?;
let range = q.range.as_deref().map(str::trim).filter(|s| !s.is_empty());
let revs: Vec<&str> = q.branch.iter().chain(&q.branches).map(|b| b.trim()).filter(|b| !b.is_empty()).collect();
let ranges: Vec<_> = q.ranges.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
let tips =
if range.is_some() || (!ranges.is_empty() && revs.is_empty()) { Vec::new() } else { tips(&repo, &revs)? };
let paths: Vec<String> = q
.path
.iter()
.chain(&q.paths)
.map(|p| r.rel_path(p))
.collect::<IdeResult<Vec<_>>>()?
.into_iter()
.filter(|p| !p.is_empty())
.collect();
let authors: Vec<_> =
q.author.iter().chain(&q.authors).map(|a| a.trim().to_lowercase()).filter(|a| !a.is_empty()).collect();
if !q.summary_only
&& q.skip == 0
&& q.limit == 1
&& range.is_none()
&& ranges.is_empty()
&& tips.len() == 1
&& paths.is_empty()
&& authors.is_empty()
&& q.text.as_deref().is_none_or(|t| t.trim().is_empty())
&& q.speed_search.as_deref().is_none_or(|t| t.trim().is_empty())
&& !q.no_merges
&& q.since_ms.is_none()
&& q.until_ms.is_none()
{
update_head_reachable(&repo, index, cancel)?;
let id = tips[0];
let record = index.get(&repo, id)?;
let mut commit = (*record).clone();
let shown_parents: Vec<String> = if shallow.contains(&id) {
Vec::new()
} else {
commit
.parents
.iter()
.take(if q.first_parent { 1 } else { usize::MAX })
.filter(|p| {
ObjectId::from_hex(p.as_bytes())
.ok()
.is_some_and(|oid| index.has_cached(&oid) || repo.has_object(oid))
})
.cloned()
.collect()
};
commit.graph_dashed = vec![false; shown_parents.len()];
commit.graph_parents = Some(shown_parents);
if shallow.contains(&id) {
commit.graph_boundary_parents = commit.parents.clone();
}
commit.on_current_branch = Some(index.reachable.contains(&id));
commit.refs = decorations.get(&id).cloned().unwrap_or_default();
commit.message = index.message(&repo, id)?;
commit.message_truncated = false;
return Ok(vec![commit]);
}
let rows = log_indexed_rows(r, q, cancel, index)?;
let db = index.db().cloned();
let txn = db.as_ref().and_then(|db| db.begin_read().ok());
let table = txn.as_ref().and_then(|t| t.open_table(crate::log_index::COMMITS).ok());
let mut out = Vec::with_capacity(rows.len());
for row in &rows {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
out.push(materialize_row(&repo, index, table.as_ref(), row, &shallow, &decorations, q.summary_only, None)?);
}
Ok(out)
}
fn log_indexed_rows(
r: &Repo,
q: &LogQuery,
cancel: &CancellationToken,
index: &mut crate::log_index::LogIndex,
) -> IdeResult<Vec<crate::log_index::IndexedRow>> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
@@ -601,14 +410,29 @@ fn log_indexed_rows(
};
let authors: Vec<_> =
q.author.iter().chain(&q.authors).map(|a| a.trim().to_lowercase()).filter(|a| !a.is_empty()).collect();
update_head_reachable(&repo, index, cancel)?;
let mut range_ids_set = range_ids.as_ref().map(|ids| ids.iter().copied().collect::<HashSet<_>>());
let mut visibility = Visibility {
speed_refs: &decorations,
query: q,
path_ids,
range_ids: range_ids.as_ref().map(|ids| ids.iter().copied().collect()),
authors,
text: q.text.as_deref().map(|t| TextFilter::new(r, &scopes, q, t, cancel)).transpose()?.flatten(),
matches: HashMap::new(),
projected: HashMap::new(),
cancel,
index,
shallow: shallow.clone(),
};
type Walk<'a> = Box<dyn Iterator<Item = IdeResult<ObjectId>> + 'a>;
let walk: Walk<'_> = if let Some(ids) = range_ids {
Box::new(ids.into_iter().map(Ok))
} else if q.summary_only {
let ids = index.graph(&repo, &tips, &shallow, q.first_parent, cancel)?;
Box::new(sort_subset(&repo, ids, q.order, q.first_parent, &shallow, cancel, &index.nodes)?.into_iter().map(Ok))
let ids = visibility.index.graph(&repo, &tips, &shallow, q.first_parent, cancel)?;
Box::new(
sort_subset(&repo, ids, q.order, q.first_parent, &shallow, cancel, &visibility.index.nodes)?
.into_iter()
.map(Ok),
)
} else if repo.is_shallow().unwrap_or(false) || q.request.is_some() {
// The process can be interrupted even during indegree preparation. Git also
// respects shallow boundaries while retaining a topological order.
@@ -649,7 +473,7 @@ fn log_indexed_rows(
let mut boundary_args = vec!["rev-list".to_string()];
let stdin = revision_args(&mut boundary_args, &revs, &[], None);
let boundaries = run_log(r, boundary_args, stdin.as_deref(), cancel)?;
range_ids_set = Some(
visibility.range_ids = Some(
boundaries
.stdout_str()
.lines()
@@ -675,30 +499,60 @@ fn log_indexed_rows(
};
let walk: Walk<'_> = if q.order == LogOrder::Standard {
let ids = walk.collect::<IdeResult<Vec<_>>>()?;
Box::new(sort_subset(&repo, ids, q.order, q.first_parent, &shallow, cancel, &index.nodes)?.into_iter().map(Ok))
Box::new(
sort_subset(&repo, ids, q.order, q.first_parent, &shallow, cancel, &visibility.index.nodes)?
.into_iter()
.map(Ok),
)
} else {
walk
};
let text = q.text.as_deref().map(|t| TextFilter::new(r, &scopes, q, t, cancel)).transpose()?.flatten();
let db = index.db().cloned();
let txn = db.as_ref().and_then(|db| db.begin_read().ok());
let table = txn.as_ref().and_then(|t| t.open_table(crate::log_index::COMMITS).ok());
let mut visibility = Visibility {
speed_refs: &decorations,
query: q,
path_ids,
range_ids: range_ids_set,
authors,
text,
matches: HashMap::new(),
projected: HashMap::new(),
cancel,
index,
table: table.as_ref(),
shallow: shallow.clone(),
};
let stop_id = q.stop_at.as_deref().and_then(|s| ObjectId::from_hex(s.as_bytes()).ok());
let unfiltered = visibility.is_unfiltered();
let head = repo.head_id().ok().map(|id| id.detach());
let shallow_stamp =
repo.shallow_file().ok().and_then(|path| std::fs::metadata(path).ok()).and_then(|m| m.modified().ok());
let stamp = head.map(|id| (id, shallow_stamp));
if visibility.index.head != stamp {
let mut additions = Vec::new();
let mut cursor = head;
let old_head = visibility.index.head;
if let (Some((old, old_shallow)), Some(_)) = (old_head, head)
&& old_shallow == shallow_stamp
{
while let Some(id) = cursor {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if id == old {
break;
}
let record = visibility.index.get(&repo, id)?;
if record.parents.len() != 1 {
cursor = None;
break;
}
additions.push(id);
cursor = Some(ObjectId::from_hex(record.parents[0].as_bytes()).map_err(gx)?);
}
} else {
cursor = None;
}
if cursor.is_some() && cursor == old_head.map(|(id, _)| id) {
visibility.index.reachable.extend(additions);
visibility.index.head = stamp;
} else {
let mut reachable = HashSet::new();
if let Some(head) = head {
for item in repo.rev_walk([head]).all().map_err(gx)? {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
reachable.insert(item.map_err(gx)?.id);
}
}
visibility.index.reachable = reachable;
visibility.index.head = stamp;
}
}
let mut out = Vec::new();
let mut skip = q.skip as usize;
let limit = if q.limit == 0 { usize::MAX } else { q.limit as usize };
@@ -707,44 +561,36 @@ fn log_indexed_rows(
return Err(IdeError::cancelled());
}
let id = item?;
if !unfiltered && !visibility.shows(&repo, id)? {
if !visibility.shows(&repo, id)? {
continue;
}
if skip > 0 {
skip -= 1;
continue;
}
let parents: Vec<ObjectId> = if shallow.contains(&id) {
Vec::new()
} else if let Some((ps, _)) = visibility.index.nodes.get(&id) {
ps.clone()
} else {
let record = visibility.index.get_with_table(&repo, id, visibility.table)?;
record
.parents
.iter()
.map(|p| ObjectId::from_hex(p.as_bytes()).map_err(gx))
.collect::<IdeResult<Vec<_>>>()?
};
let shown_parents = if shallow.contains(&id) {
Vec::new()
} else if unfiltered {
parents
.into_iter()
.take(if q.first_parent { 1 } else { usize::MAX })
.filter(|oid| visibility.index.has_cached(oid) || repo.has_object(*oid))
.collect()
} else {
visibility.parents(&repo, &parents)?
};
let on_current_branch = visibility.index.reachable.contains(&id);
out.push(crate::log_index::IndexedRow {
id,
graph_parents: shown_parents,
on_current_branch,
history_path: history_paths.get(&id).cloned(),
});
if out.len() >= limit || stop_id == Some(id) {
let record = visibility.index.get(&repo, id)?;
let parents = record
.parents
.iter()
.map(|p| ObjectId::from_hex(p.as_bytes()).map_err(gx))
.collect::<IdeResult<Vec<_>>>()?;
let shown_parents = if shallow.contains(&id) { Vec::new() } else { visibility.parents(&repo, &parents)? };
let mut commit = (*record).clone();
commit.graph_dashed = shown_parents.iter().map(|p| !parents.contains(p)).collect();
commit.dashed = if commit.graph_dashed.iter().any(|d| *d) { commit.graph_dashed.clone() } else { Vec::new() };
commit.graph_parents = Some(shown_parents.iter().map(ToString::to_string).collect());
if shallow.contains(&id) {
commit.graph_boundary_parents = commit.parents.clone();
}
commit.on_current_branch = Some(visibility.index.reachable.contains(&id));
commit.refs = decorations.get(&id).cloned().unwrap_or_default();
commit.history_path = history_paths.get(&id).cloned();
if !q.summary_only {
commit.message = visibility.index.message(&repo, id)?;
commit.message_truncated = false;
}
out.push(commit);
if out.len() >= limit || q.stop_at.as_deref() == Some(id.to_string().as_str()) {
break;
}
}
@@ -766,7 +612,6 @@ fn sort_subset(
let mut parents = Vec::with_capacity(ids.len());
let mut children = vec![0usize; ids.len()];
let mut times = Vec::with_capacity(ids.len());
let mut projected: HashMap<ObjectId, Vec<usize>> = HashMap::new();
for id in &ids {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
@@ -778,7 +623,8 @@ fn sort_subset(
(commit.parent_ids().map(|p| p.detach()).collect(), commit.time().map(|t| t.seconds).unwrap_or(0))
};
times.push(time);
let direct: Vec<_> = all_parents
let mut ordered = Vec::new();
let mut pending: Vec<_> = all_parents
.into_iter()
.take(if shallow.contains(id) {
0
@@ -788,70 +634,28 @@ fn sort_subset(
usize::MAX
})
.collect();
let mut ordered = Vec::with_capacity(direct.len());
if direct.iter().all(|p| positions.contains_key(p)) {
for p in direct {
let pos = positions[&p];
if !ordered.contains(&pos) {
ordered.push(pos);
}
let mut visited = HashSet::new();
while let Some(parent) = pending.pop() {
if !visited.insert(parent) {
continue;
}
} else {
for parent in direct {
if let Some(&pos) = positions.get(&parent) {
if !ordered.contains(&pos) {
ordered.push(pos);
}
continue;
}
let mut stack = vec![(parent, false)];
while let Some((curr, expanded)) = stack.pop() {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if projected.contains_key(&curr) {
continue;
}
if let Some(&pos) = positions.get(&curr) {
projected.insert(curr, vec![pos]);
} else if shallow.contains(&curr) || (!nodes.contains_key(&curr) && !repo.has_object(curr)) {
projected.insert(curr, Vec::new());
} else {
let ancestors: Vec<_> = if let Some((ps, _)) = nodes.get(&curr) {
ps.iter().take(if first_parent { 1 } else { usize::MAX }).copied().collect()
} else {
repo.find_commit(curr)
.map_err(gx)?
.parent_ids()
.take(if first_parent { 1 } else { usize::MAX })
.map(|p| p.detach())
.collect()
};
if !expanded {
stack.push((curr, true));
stack.extend(ancestors.iter().rev().map(|&p| (p, false)));
} else {
let mut combined = Vec::new();
for a in ancestors {
for &p in projected.get(&a).into_iter().flatten() {
if !combined.contains(&p) {
combined.push(p);
}
}
}
projected.insert(curr, combined);
}
}
}
for &pos in projected.get(&parent).into_iter().flatten() {
if !ordered.contains(&pos) {
ordered.push(pos);
}
if let Some(position) = positions.get(&parent) {
if !ordered.contains(position) {
ordered.push(*position);
}
} else if !shallow.contains(&parent) && repo.has_object(parent) {
pending.extend(
repo.find_commit(parent)
.map_err(gx)?
.parent_ids()
.take(if first_parent { 1 } else { usize::MAX })
.map(|p| p.detach()),
);
}
}
for &p in &ordered {
children[p] += 1;
ordered.reverse();
for p in &ordered {
children[*p] += 1;
}
parents.push(ordered);
}
@@ -945,22 +749,10 @@ struct Visibility<'a> {
projected: HashMap<ObjectId, Vec<ObjectId>>,
cancel: &'a CancellationToken,
index: &'a mut crate::log_index::LogIndex,
table: Option<&'a redb::ReadOnlyTable<&'static str, &'static [u8]>>,
shallow: HashSet<ObjectId>,
}
impl Visibility<'_> {
fn is_unfiltered(&self) -> bool {
self.path_ids.is_none()
&& self.range_ids.is_none()
&& !self.query.no_merges
&& self.query.since_ms.is_none()
&& self.query.until_ms.is_none()
&& self.query.speed_search.is_none()
&& self.authors.is_empty()
&& self.text.is_none()
}
fn shows(&mut self, repo: &gix::Repository, id: ObjectId) -> IdeResult<bool> {
if self.cancel.is_cancelled() {
return Err(IdeError::cancelled());
@@ -968,23 +760,16 @@ impl Visibility<'_> {
if let Some(shown) = self.matches.get(&id) {
return Ok(*shown);
}
let shown = if (!self.index.has_cached(&id) && !repo.has_object(id))
let shown = if !repo.has_object(id)
|| self.path_ids.as_ref().is_some_and(|ids| !ids.contains(&id))
|| self.range_ids.as_ref().is_some_and(|ids| !ids.contains(&id))
{
false
} else if self.authors.is_empty()
&& self.query.speed_search.is_none()
&& self.text.is_none()
&& let Some((parents, time_s)) = self.index.nodes.get(&id)
{
let time = (*time_s as f64) * 1000.0;
!(self.query.no_merges && parents.len() > 1)
&& self.query.since_ms.is_none_or(|s| time >= s)
&& self.query.until_ms.is_none_or(|u| time < u)
} else {
let commit = self.index.get_with_table(repo, id, self.table)?;
let commit = self.index.get(repo, id)?;
let time = commit.commit_time_ms;
let name = commit.author.to_lowercase();
let email = commit.email.to_lowercase();
!(self.query.no_merges && commit.parents.len() > 1)
&& self.query.since_ms.is_none_or(|s| time >= s)
&& self.query.until_ms.is_none_or(|u| time < u)
@@ -1009,17 +794,14 @@ impl Visibility<'_> {
})
})
})
&& (self.authors.is_empty() || {
let name = commit.author.to_lowercase();
let email = commit.email.to_lowercase();
self.authors.iter().any(|a| {
&& (self.authors.is_empty()
|| self.authors.iter().any(|a| {
if self.query.authors_exact {
crate::log_index::matches_user(a, &name, &email)
} else {
name.contains(a.as_str()) || email.contains(a.as_str())
}
})
})
}))
&& match &self.text {
Some(text) => text.matches(repo, self.index, id)?,
None => true,
@@ -1041,22 +823,22 @@ impl Visibility<'_> {
}
if self.shows(repo, id)? {
self.projected.insert(id, vec![id]);
} else if !self.index.has_cached(&id) && !repo.has_object(id) {
} else if !repo.has_object(id) {
self.projected.insert(id, Vec::new());
} else {
let ancestors = if self.shallow.contains(&id) {
Vec::new()
} else if let Some((ps, _)) = self.index.nodes.get(&id) {
ps.iter().take(if self.query.first_parent { 1 } else { usize::MAX }).copied().collect()
} else {
let commit = self.index.get_with_table(repo, id, self.table)?;
commit
.parents
.iter()
.take(if self.query.first_parent { 1 } else { usize::MAX })
.map(|p| ObjectId::from_hex(p.as_bytes()).map_err(gx))
.collect::<IdeResult<Vec<_>>>()?
};
let commit = self.index.get(repo, id)?;
let ancestors = commit
.parents
.iter()
.take(if self.shallow.contains(&id) {
0
} else if self.query.first_parent {
1
} else {
usize::MAX
})
.map(|p| ObjectId::from_hex(p.as_bytes()).map_err(gx))
.collect::<IdeResult<Vec<_>>>()?;
if !expanded {
stack.push((id, true));
stack.extend(ancestors.iter().rev().map(|p| (*p, false)));
+47 -74
View File
@@ -902,86 +902,59 @@ impl Repo {
self.log_details_cancellable(revision, &CancellationToken::new())
}
pub fn log_details_cancellable(&self, revision: &str, cancel: &CancellationToken) -> IdeResult<LogDetails> {
self.log_details_batch_cancellable(&[revision.to_string()], cancel)?
.into_iter()
.next()
.ok_or_else(|| IdeError::not_found("commit not found"))
}
pub fn log_details_batch_cancellable(
&self,
revisions: &[String],
cancel: &CancellationToken,
) -> IdeResult<Vec<LogDetails>> {
let allowed = self.execution_allowed.load(std::sync::atomic::Ordering::Acquire);
let _scope = crate::ExecutionScope::enter(allowed);
let repo = self.gix()?;
let shallow: std::collections::HashSet<gix::ObjectId> =
repo.shallow_commits().map_err(repo::gx)?.map(|ids| ids.iter().copied().collect()).unwrap_or_default();
let mut out = Vec::with_capacity(revisions.len());
let mut commit_ids = Vec::with_capacity(revisions.len());
for revision in revisions {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let object = repo
.find_object(repo::resolve(&repo, revision)?)
.map_err(repo::gx)?
.peel_to_commit()
.map_err(repo::gx)?;
let commit_id = object.id;
commit_ids.push(commit_id);
let id = commit_id.to_string();
let cache_key = format!("{allowed}:{id}");
let cached = self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).get(&cache_key).cloned();
let details = if let Some(details) = cached {
details
let object =
repo.find_object(repo::resolve(&repo, revision)?).map_err(repo::gx)?.peel_to_commit().map_err(repo::gx)?;
let commit_id = object.id;
let id = commit_id.to_string();
let contains = format!("--contains={id}");
let branches =
cli::run_cancellable(self.root(), &["branch", "-a", &contains, refs::DIRECT_BRANCH_FORMAT], cancel)?;
let cache_key = format!("{allowed}:{id}");
let cached = self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).get(&cache_key).cloned();
let mut details = if let Some(details) = cached {
details
} else {
let end = object.data.windows(2).position(|s| s == b"\n\n").unwrap_or(object.data.len());
let headers = &object.data[..end];
let signed = headers.windows(8).any(|s| s == b"\ngpgsig ") || headers.windows(8).any(|s| s == b"\ngpgsig-");
let output = if allowed && signed {
Some(cli::run_cancellable(
self.root(),
&["log", "-1", "--format=%G?%x00%GS%x00%GK%x00%GF", "--end-of-options", &id],
cancel,
)?)
} else {
let end = object.data.windows(2).position(|s| s == b"\n\n").unwrap_or(object.data.len());
let headers = &object.data[..end];
let signed =
headers.windows(8).any(|s| s == b"\ngpgsig ") || headers.windows(8).any(|s| s == b"\ngpgsig-");
let output = if allowed && signed {
Some(cli::run_cancellable(
self.root(),
&["log", "-1", "--format=%G?%x00%GS%x00%GK%x00%GF", "--end-of-options", &id],
cancel,
)?)
} else {
None
};
let text = output.as_ref().map(|out| out.stdout_str()).unwrap_or_default();
let fields: Vec<_> = text.trim_end().split('\0').collect();
let message = {
let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner());
index.message(&repo, commit_id)?
};
let details = LogDetails {
message,
containing_branches: Vec::new(),
signature: if !signed {
"N".into()
} else {
fields.first().filter(|s| !s.is_empty()).unwrap_or(&"E").to_string()
},
signer: fields.get(1).unwrap_or(&"").to_string(),
key: fields.get(2).unwrap_or(&"").to_string(),
fingerprint: fields.get(3).unwrap_or(&"").to_string(),
};
self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).insert(cache_key, details.clone());
details
None
};
out.push(details);
}
let branches_per_commit = {
let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner());
let res = index.containing_branches_batch(&repo, &commit_ids, &shallow, cancel)?;
index.flush();
res
let text = output.as_ref().map(|out| out.stdout_str()).unwrap_or_default();
let fields: Vec<_> = text.trim_end().split('\0').collect();
let message = {
let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner());
let message = index.message(&repo, commit_id)?;
index.flush();
message
};
let details = LogDetails {
message,
containing_branches: Vec::new(),
signature: if !signed {
"N".into()
} else {
fields.first().filter(|s| !s.is_empty()).unwrap_or(&"E").to_string()
},
signer: fields.get(1).unwrap_or(&"").to_string(),
key: fields.get(2).unwrap_or(&"").to_string(),
fingerprint: fields.get(3).unwrap_or(&"").to_string(),
};
self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).insert(cache_key, details.clone());
details
};
for (details, branches) in out.iter_mut().zip(branches_per_commit) {
details.containing_branches = branches;
}
Ok(out)
details.containing_branches =
branches.stdout_str().lines().filter(|s| !s.is_empty()).map(str::to_string).collect();
Ok(details)
}
/// Lists one immutable revision directory. Directory names end in `/`.
+70 -332
View File
@@ -6,136 +6,41 @@ use crate::repo::{gx, to_string};
use gix::ObjectId;
use gix::bstr::ByteSlice;
use ide_core::IdeResult;
use redb::{Database, ReadOnlyTable, ReadableDatabase, TableDefinition};
use redb::{Database, ReadableDatabase, TableDefinition};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::SystemTime;
pub(crate) const COMMITS: TableDefinition<&str, &[u8]> = TableDefinition::new("summaries-v2");
const COMMITS: TableDefinition<&str, &[u8]> = TableDefinition::new("summaries-v2");
const MESSAGES: TableDefinition<&str, &str> = TableDefinition::new("messages-v1");
const MAX_MEMORY_COMMITS: usize = 4096;
const MAX_MEMORY_MESSAGES: usize = 1024;
const MAX_DIRTY_BATCH: usize = 16_384;
pub(crate) struct IndexedRow {
pub id: ObjectId,
pub graph_parents: Vec<ObjectId>,
pub on_current_branch: bool,
pub history_path: Option<String>,
}
pub(crate) struct IndexedView {
pub rows: Vec<IndexedRow>,
pub positions: HashMap<ObjectId, usize>,
pub commits: Vec<Commit>,
pub positions: HashMap<String, usize>,
}
impl IndexedView {
pub fn new(rows: Vec<IndexedRow>) -> Self {
let positions = rows.iter().enumerate().map(|(i, r)| (r.id, i)).collect();
Self { rows, positions }
pub fn new(commits: Vec<Commit>) -> Self {
let positions = commits.iter().enumerate().map(|(i, c)| (c.id.clone(), i)).collect();
Self { commits, positions }
}
}
type NodeEntry = (Vec<ObjectId>, i64);
#[derive(Default)]
pub(crate) struct LogIndex {
opened: bool,
db: Option<Arc<Database>>,
memory: HashMap<ObjectId, Arc<Commit>>,
messages: HashMap<ObjectId, String>,
dirty: Vec<Arc<Commit>>,
dirty_messages: Vec<(String, String)>,
pub head: Option<(ObjectId, Option<SystemTime>)>,
pub reachable: HashSet<ObjectId>,
pub views: HashMap<String, Arc<IndexedView>>,
pub nodes: HashMap<ObjectId, NodeEntry>,
pub nodes: HashMap<ObjectId, (Vec<ObjectId>, i64)>,
pub shallow: HashSet<ObjectId>,
}
impl LogIndex {
pub fn db(&self) -> Option<&Arc<Database>> {
self.db.as_ref()
}
pub fn has_cached(&self, id: &ObjectId) -> bool {
self.memory.contains_key(id) || self.nodes.contains_key(id)
}
/// Populates `self.nodes` in post-order so a node is inserted only after all of
/// its ancestors are in `self.nodes` (preserving completeness across cancellations).
pub fn ensure_nodes(
&mut self,
repo: &gix::Repository,
tips: &[ObjectId],
shallow: &HashSet<ObjectId>,
cancel: &ide_core::CancellationToken,
) -> IdeResult<()> {
if &self.shallow != shallow {
self.nodes.clear();
self.shallow = shallow.clone();
}
if tips.iter().all(|id| self.nodes.contains_key(id)) {
return Ok(());
}
let cg = repo.commit_graph_if_enabled().ok().flatten();
let db = self.db.clone();
let txn = db.as_ref().and_then(|db| db.begin_read().ok());
let table = txn.as_ref().and_then(|t| t.open_table(COMMITS).ok());
let mut stack: Vec<(ObjectId, Option<NodeEntry>)> =
tips.iter().copied().filter(|id| !self.nodes.contains_key(id)).map(|id| (id, None)).collect();
let mut visiting: HashSet<ObjectId> = HashSet::new();
while let Some((id, state)) = stack.pop() {
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
if let Some(entry) = state {
visiting.remove(&id);
self.nodes.insert(id, entry);
continue;
}
if self.nodes.contains_key(&id) || !visiting.insert(id) {
continue;
}
let (parents, time) = if let Some(cg_commit) = cg.as_ref().and_then(|g| g.commit_by_id(id)) {
let parents = if shallow.contains(&id) {
Vec::new()
} else {
let g = cg.as_ref().unwrap();
cg_commit
.iter_parents()
.map(|p| p.map(|pos| g.id_at(pos).to_owned()).map_err(gx))
.collect::<IdeResult<Vec<_>>>()?
};
(parents, cg_commit.committer_timestamp() as i64)
} else {
let record = self.get_with_table(repo, id, table.as_ref())?;
let parents = if shallow.contains(&id) {
Vec::new()
} else {
record
.parents
.iter()
.map(|s| ObjectId::from_hex(s.as_bytes()).map_err(gx))
.collect::<IdeResult<Vec<_>>>()?
};
(parents, (record.commit_time_ms / 1000.0) as i64)
};
stack.push((id, Some((parents.clone(), time))));
for parent in parents.into_iter().rev() {
if !self.nodes.contains_key(&parent) && !visiting.contains(&parent) {
stack.push((parent, None));
}
}
}
drop(table);
drop(txn);
if self.dirty.len() >= MAX_DIRTY_BATCH {
self.flush();
}
Ok(())
}
/// A permanent DAG: refresh discovers only new objects, then joins arbitrary
/// changed tips against known ancestry. Reachability is recomputed for resets/deleted refs.
pub fn graph(
@@ -146,7 +51,27 @@ impl LogIndex {
first_parent: bool,
cancel: &ide_core::CancellationToken,
) -> IdeResult<Vec<ObjectId>> {
self.ensure_nodes(repo, tips, shallow, cancel)?;
if &self.shallow != shallow {
self.nodes.clear();
self.shallow = shallow.clone();
}
let mut pending = tips.to_vec();
while let Some(id) = pending.pop() {
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
if self.nodes.contains_key(&id) {
continue;
}
let record = self.get(repo, id)?;
let parents: Vec<_> = if shallow.contains(&id) {
Vec::new()
} else {
record.parents.iter().map(|s| ObjectId::from_hex(s.as_bytes()).map_err(gx)).collect::<IdeResult<_>>()?
};
pending.extend(parents.iter().copied());
self.nodes.insert(id, (parents, (record.commit_time_ms / 1000.0) as i64));
}
let mut seen = HashSet::new();
let mut ids = Vec::new();
let mut pending = tips.to_vec();
@@ -161,101 +86,6 @@ impl LogIndex {
}
Ok(ids)
}
/// Non-symbolic local and remote branches whose tip can reach each commit in `commits`.
pub fn containing_branches_batch(
&mut self,
repo: &gix::Repository,
commits: &[ObjectId],
shallow: &HashSet<ObjectId>,
cancel: &ide_core::CancellationToken,
) -> IdeResult<Vec<Vec<String>>> {
if commits.is_empty() {
return Ok(Vec::new());
}
let mut branch_tips: Vec<(String, ObjectId)> = Vec::new();
let mut unique_tips: Vec<ObjectId> = Vec::new();
let platform = repo.references().map_err(gx)?;
for reference in platform.all().map_err(gx)? {
let Ok(mut reference) = reference else { continue };
if reference.target().try_name().is_some() {
continue;
}
let full = reference.name().as_bstr().to_owned();
let Some(short) = full.strip_prefix(b"refs/heads/").or_else(|| full.strip_prefix(b"refs/remotes/")) else {
continue;
};
let label = to_string(short.as_bstr());
let Ok(id) = reference.peel_to_id().map(|id| id.detach()) else { continue };
if !self.nodes.contains_key(&id) && repo.find_commit(id).is_err() {
continue;
}
if !unique_tips.contains(&id) {
unique_tips.push(id);
}
branch_tips.push((label, id));
}
self.ensure_nodes(repo, &unique_tips, shallow, cancel)?;
let mut out = Vec::with_capacity(commits.len());
for chunk in commits.chunks(64) {
let mut reaches: HashMap<ObjectId, u64> = HashMap::new();
let mut stack: Vec<(ObjectId, bool)> = Vec::new();
for &tip in &unique_tips {
if reaches.contains_key(&tip) {
continue;
}
stack.push((tip, false));
while let Some((node, expanded)) = stack.pop() {
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
if reaches.contains_key(&node) {
continue;
}
if !expanded {
stack.push((node, true));
if let Some((parents, _)) = self.nodes.get(&node) {
for &p in parents.iter().rev() {
if !reaches.contains_key(&p) {
stack.push((p, false));
}
}
}
} else {
let mut mask = 0u64;
for (bit, &target) in chunk.iter().enumerate() {
if node == target {
mask |= 1u64 << bit;
}
}
if let Some((parents, _)) = self.nodes.get(&node) {
for &p in parents {
mask |= reaches.get(&p).copied().unwrap_or(0);
}
}
reaches.insert(node, mask);
}
}
}
for (bit, &target) in chunk.iter().enumerate() {
if !self.nodes.contains_key(&target) {
out.push(Vec::new());
continue;
}
let flag = 1u64 << bit;
let mut names: Vec<String> = branch_tips
.iter()
.filter(|(_, tip)| (reaches.get(tip).copied().unwrap_or(0) & flag) != 0)
.map(|(name, _)| name.clone())
.collect();
names.sort();
names.dedup();
out.push(names);
}
}
Ok(out)
}
pub fn open(&mut self, dir: &Path) {
if self.opened {
return;
@@ -274,34 +104,16 @@ impl LogIndex {
}
pub fn get(&mut self, repo: &gix::Repository, id: ObjectId) -> IdeResult<Arc<Commit>> {
self.get_with_table(repo, id, None)
}
pub fn get_with_table(
&mut self,
repo: &gix::Repository,
id: ObjectId,
open_table: Option<&ReadOnlyTable<&str, &[u8]>>,
) -> IdeResult<Arc<Commit>> {
if let Some(commit) = self.memory.get(&id) {
return Ok(commit.clone());
}
let key = id.to_string();
let cached = if let Some(table) = open_table {
table
.get(key.as_str())
.ok()
.flatten()
.and_then(|v| serde_json::from_slice::<Commit>(v.value()).ok())
.filter(|c| c.id == key)
} else {
self.db.as_ref().and_then(|db| {
let txn = db.begin_read().ok()?;
let table = txn.open_table(COMMITS).ok()?;
let value = table.get(key.as_str()).ok()??;
serde_json::from_slice::<Commit>(value.value()).ok().filter(|c| c.id == key)
})
};
let cached = self.db.as_ref().and_then(|db| {
let txn = db.begin_read().ok()?;
let table = txn.open_table(COMMITS).ok()?;
let value = table.get(key.as_str()).ok()??;
serde_json::from_slice::<Commit>(value.value()).ok().filter(|c| c.id == key)
});
let commit = if let Some(commit) = cached {
Arc::new(commit)
} else {
@@ -336,14 +148,12 @@ impl LogIndex {
});
if self.db.is_some() {
self.dirty.push(record.clone());
if open_table.is_none() && self.dirty.len() >= MAX_DIRTY_BATCH {
self.flush();
}
}
record
};
// Keep hot pages in memory; older metadata stays in the persistent index.
if self.memory.len() >= MAX_MEMORY_COMMITS {
if self.memory.len() >= 4096 {
self.flush();
self.memory.clear();
}
self.memory.insert(id, commit.clone());
@@ -351,7 +161,7 @@ impl LogIndex {
}
pub fn message(&mut self, repo: &gix::Repository, id: ObjectId) -> IdeResult<String> {
if let Some(message) = self.messages.get(&id) {
if let Some((_, message)) = self.dirty_messages.iter().find(|(key, _)| key == &id.to_string()) {
return Ok(message.clone());
}
let key = id.to_string();
@@ -360,54 +170,46 @@ impl LogIndex {
let table = txn.open_table(MESSAGES).ok()?;
Some(table.get(key.as_str()).ok()??.value().to_string())
});
let message = if let Some(message) = cached {
message
} else {
let message = to_string(repo.find_commit(id).map_err(gx)?.message_raw_sloppy()).trim_end().to_string();
if self.db.is_some() {
self.dirty_messages.push((key, message.clone()));
}
message
};
if self.messages.len() >= MAX_MEMORY_MESSAGES {
self.messages.clear();
if let Some(message) = cached {
return Ok(message);
}
let message = to_string(repo.find_commit(id).map_err(gx)?.message_raw_sloppy()).trim_end().to_string();
if self.db.is_some() {
self.dirty_messages.push((key, message.clone()));
}
self.messages.insert(id, message.clone());
Ok(message)
}
pub fn flush(&mut self) {
if !self.dirty.is_empty() || !self.dirty_messages.is_empty() {
let result = (|| -> Result<(), Box<dyn std::error::Error>> {
let Some(db) = &self.db else {
return Ok(());
};
let txn = db.begin_write()?;
{
let mut table = txn.open_table(COMMITS)?;
for commit in &self.dirty {
let bytes = serde_json::to_vec(commit.as_ref())?;
table.insert(commit.id.as_str(), bytes.as_slice())?;
}
if self.dirty.is_empty() && self.dirty_messages.is_empty() {
return;
}
let result = (|| -> Result<(), Box<dyn std::error::Error>> {
let Some(db) = &self.db else {
return Ok(());
};
let txn = db.begin_write()?;
{
let mut table = txn.open_table(COMMITS)?;
for commit in &self.dirty {
let bytes = serde_json::to_vec(commit.as_ref())?;
table.insert(commit.id.as_str(), bytes.as_slice())?;
}
{
let mut table = txn.open_table(MESSAGES)?;
for (id, message) in &self.dirty_messages {
table.insert(id.as_str(), message.as_str())?;
}
}
txn.commit()?;
Ok(())
})();
if let Err(error) = result {
tracing::warn!("Git log cache write failed: {error}");
}
self.dirty_messages.clear();
self.dirty.clear();
}
if self.memory.len() > MAX_MEMORY_COMMITS {
self.memory.clear();
{
let mut table = txn.open_table(MESSAGES)?;
for (id, message) in &self.dirty_messages {
table.insert(id.as_str(), message.as_str())?;
}
}
txn.commit()?;
Ok(())
})();
if let Err(error) = result {
tracing::warn!("Git log cache write failed: {error}");
}
self.dirty_messages.clear();
self.dirty.clear();
}
}
@@ -424,67 +226,3 @@ pub(crate) fn matches_user(query: &str, name: &str, email: &str) -> bool {
.iter()
.any(|alias| target.contains(&normalize_user(alias)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args([
"-c",
"init.defaultBranch=main",
"-c",
"user.name=Test",
"-c",
"[email protected]",
"-c",
"commit.gpgsign=false",
])
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn commit(dir: &Path, file: &str, msg: &str) -> ObjectId {
std::fs::write(dir.join(file), msg).unwrap();
git(dir, &["add", "-A"]);
git(dir, &["commit", "-qm", msg]);
ObjectId::from_hex(git(dir, &["rev-parse", "HEAD"]).as_bytes()).unwrap()
}
#[test]
fn postorder_ensure_nodes_and_containing_branches_stay_complete_across_partial_state() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let c0 = commit(dir, "a.txt", "c0");
let c1 = commit(dir, "a.txt", "c1");
let c2 = commit(dir, "a.txt", "c2");
git(dir, &["branch", "feature", &c1.to_string()]);
let repo = gix::open(dir).unwrap();
let shallow = HashSet::new();
let mut index = LogIndex::default();
// Populate up to c1 first, then ensure c2 reuses c0..c1 and completes up to c2.
index.ensure_nodes(&repo, &[c1], &shallow, &ide_core::CancellationToken::new()).unwrap();
assert!(index.nodes.contains_key(&c0) && index.nodes.contains_key(&c1));
assert!(!index.nodes.contains_key(&c2));
let cancel = ide_core::CancellationToken::new();
cancel.cancel();
assert!(index.ensure_nodes(&repo, &[c2], &shallow, &cancel).is_err());
assert!(!index.nodes.contains_key(&c2));
let branches = index
.containing_branches_batch(&repo, &[c0, c1, c2], &shallow, &ide_core::CancellationToken::new())
.unwrap();
assert_eq!(branches[0], vec!["feature".to_string(), "main".to_string()]);
assert_eq!(branches[1], vec!["feature".to_string(), "main".to_string()]);
assert_eq!(branches[2], vec!["main".to_string()]);
}
}
-18
View File
@@ -95,15 +95,6 @@ fn lazy_summaries_seek_and_index_survive_refresh_and_restart() {
let window = repo.log(&LogQuery { seek: Some(ids[5].clone()), limit: 20, ..query.clone() }).unwrap();
assert!(window.iter().any(|c| c.id == ids[5]));
assert!(window[0].log_position.unwrap() > 0);
let prefix_window =
repo.log(&LogQuery { seek: Some(ids[5][..8].to_string()), limit: 20, ..query.clone() }).unwrap();
assert!(prefix_window.iter().any(|c| c.id == ids[5]));
let single = repo.log(&LogQuery { branch: Some(ids[5].clone()), skip: 0, limit: 1, ..Default::default() }).unwrap();
assert_eq!(single.len(), 1);
assert_eq!(single[0].id, ids[5]);
assert_eq!(single[0].message, "summary 5\n\nneedle body 5");
assert!(!single[0].message_truncated);
assert_eq!(single[0].on_current_branch, Some(true));
assert!(repo.log(&LogQuery { request: Some(ide_core::RequestId(1)), ..query.clone() }).is_ok());
let cancel = ide_core::CancellationToken::new();
cancel.cancel();
@@ -120,15 +111,6 @@ fn lazy_summaries_seek_and_index_survive_refresh_and_restart() {
assert_eq!(hit[0].id, ids[69]);
assert_eq!(repo.log_details(&ids[69]).unwrap().message, "summary 69\n\nneedle body 69");
assert_eq!(repo.log_details(&ids[69]).unwrap().signature, "N");
git(dir, &["checkout", "-qb", "side", &ids[0]]);
let side = commit(dir, "side.txt", "side commit");
git(dir, &["checkout", "-q", "main"]);
let fresh_repo = Repo::discover(dir).unwrap().unwrap();
let off_branch = fresh_repo.log(&LogQuery { branch: Some(side), skip: 0, limit: 1, ..Default::default() }).unwrap();
assert_eq!(off_branch[0].on_current_branch, Some(false));
let on_branch =
fresh_repo.log(&LogQuery { branch: Some(ids[5].clone()), skip: 0, limit: 1, ..Default::default() }).unwrap();
assert_eq!(on_branch[0].on_current_branch, Some(true));
}
#[test]
File renamed without changes.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

+41
View File
@@ -0,0 +1,41 @@
# Command-line launcher
Install the `openidea` command from **OpenIDEA → Install 'openidea' Command** on
macOS, or **Tools → Install 'openidea' Command** on Windows and Linux. The same
action is available in Find Action. Open a new terminal after installation.
- **macOS:** move OpenIDEA to Applications first. The command links to the launcher
inside the app bundle. It uses `~/.local/bin` when that directory is on PATH;
otherwise it uses `/usr/local/bin` and may request an administrator password.
- **Windows:** installation creates `openidea.cmd` in
`%LOCALAPPDATA%\OpenIDEA\bin` and adds that directory to the user PATH. This
works with installed and portable copies, without administrator rights.
- **Linux:** installation creates `~/.local/bin/openidea`. If another command
takes precedence or this directory is absent from PATH, the notification asks
you to put it at the beginning of your shell's PATH. For Bash and Zsh, add
`export PATH="$HOME/.local/bin:$PATH"` to your shell profile. For Fish, run
`fish_add_path --prepend ~/.local/bin`. An AppImage launcher targets the original
AppImage; keep that file in a permanent location.
```sh
openidea .
openidea src/main.rs README.md
openidea --line 12 --column 5 src/main.rs
openidea src/main.rs:12:5
openidea diff before.txt after.txt
openidea --help
openidea --version
```
Folders open as projects. Files open in their project's window, or in LightEdit
when no project contains them. Lines and columns start at 1. With no paths, the
command focuses OpenIDEA. It starts the app if necessary and returns after handing
off the request. Unknown options and missing files exit with code 1.
Use `--` before a filename beginning with `-`, or before a file named `diff`.
The existing GUI executable and OS file associations still work.
**Uninstall 'openidea' Command** removes only OpenIDEA's launcher, and on Windows
its dedicated user PATH entry. A command belonging to another application is
never overwritten or removed. Reinstall the command after moving a portable
copy or AppImage to a different location.
+27
View File
@@ -0,0 +1,27 @@
# Data directories
OpenIDEA keeps its rebuildable data, such as downloaded tools, language servers,
JDKs and index caches, in the operating system's local data directory:
- **Windows:** `%LOCALAPPDATA%\ai.l0ng.openidea`
- **macOS:** `~/Library/Application Support/ai.l0ng.openidea`
- **Linux:** `~/.local/share/ai.l0ng.openidea`
To keep this data elsewhere, for example on another drive, set
`OPENIDEA_LOCAL_DATA_DIR` to an absolute path before starting OpenIDEA. The IDE
then uses `<OPENIDEA_LOCAL_DATA_DIR>/ai.l0ng.openidea` and creates it when
missing. If the path is relative or cannot be written, OpenIDEA shows an error
and exits instead of falling back to the default location.
```bat
setx OPENIDEA_LOCAL_DATA_DIR "E:\OpenIDEA"
```
Settings, plugins, scratches and other user data stay in the operating system's
data and configuration directories. Logs stay in their usual location, so they
are still written when the variable is wrong. Existing data is not moved: quit
OpenIDEA and move only `tools/`, `language-server-runtimes/`, `index/`, `cache/`,
`lsp/`, `notebook-runtime/`, `server/`, `logs/`, `agents/tools/`,
`agents/registry.json` and top-level `java-usages-*.jar` / `java-usages-*.tmp` to
the new location, preserving their relative paths, or let the IDE download and
rebuild them. Leave everything else in place.
+69
View File
@@ -0,0 +1,69 @@
# Databases
The Database tool window (View ▸ Tool Windows ▸ Database) connects to SQLite,
PostgreSQL, MySQL/MariaDB, Microsoft SQL Server, ClickHouse and Oracle, plus the
wire-compatible products listed in **New ▸ Data Source**. Connections are saved
per project in `.openidea/data-sources.toml`; passwords live outside the project
(and never in that file).
The Database tree groups procedures and functions in one `routines` folder by
default. Enable **Separate Procedures and Functions** in the Database window’s
**Options** menu to show `procedures` and `functions` folders instead. This view
option is remembered across sessions.
## Oracle: Oracle Instant Client
Oracle support uses the Oracle Call Interface, so the Oracle client libraries
must be installed on the machine. OpenIDEA does not bundle them: install the
**Oracle Instant Client** for your platform and point the data source at it.
1. Download the _Basic_ (or _Basic Light_) package of Oracle Instant Client
19c or later for your OS from
<https://www.oracle.com/database/technologies/instant-client/downloads.html>.
2. Unpack it somewhere stable, then, in the data source's **Options** tab, set
**Instant Client** to that directory.
3. General tab: host, port (1521), and the **service name** (for example
`ORCLPDB1`) in the Database field. Connect with the schema's user and its
password.
`java.sql`-style URLs also work in the URL field:
```
oracle://scott:tiger@db.example.com:1521/ORCLPDB1
jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1
```
A TNS descriptor or alias in the Database field is passed to the client as is.
### Platform notes
- **macOS** — the _Basic_ ZIP for ARM (Apple silicon) or x86-64 must match the
app's architecture. Remove the quarantine flag if macOS blocks the libraries:
`xattr -dr com.apple.quarantine /opt/oracle/instantclient_23_5`.
- **Windows** — use the 64-bit package; the app is 64-bit. `PATH` does not have
to include the directory when the Options tab names it.
- **Linux** — install the RPM/DEB or unzip the ZIP; the directory needs the
`libclntsh.so` and `libclntshcore.so` libraries (the ZIP's `libaio`-based
builds also need the `libaio` package).
Without a working client, Test Connection fails with a clear message
(`DPI-1047: Cannot locate a 64-bit Oracle Client library…`). If the Options tab
is empty and `ORACLE_HOME` is not set, the client is looked up on the system
search path.
### What Oracle data sources support
- Schemas = Oracle users (`ALL_USERS`); system accounts (`SYS`, `SYSTEM`,
`XDB`, `MDSYS`…) appear only with **Schemas ▸ Show system**.
- Tables, views, columns, indexes, foreign keys, triggers, procedures, functions
and sequences are read from the `ALL_*` views.
- The query console runs SQL and PL/SQL blocks; `BEGIN … END;` blocks (nested
`;` included) are sent as one statement, and a lone `/` on its own line ends
one as in SQL*Plus.
- Explain Plan runs `EXPLAIN PLAN` and reads `PLAN_TABLE` (`DBMS_XPLAN` output
is not required).
- Table editor (INSERT/UPDATE/DELETE in one transaction), Import/Export and
generated SQL use Oracle syntax: `"UPPER_CASE"` identifiers, `FETCH FIRST n
ROWS ONLY`, `MODIFY (…)` for column changes.
Table editing needs a primary key, and the connection must not be read-only.
+21
View File
@@ -0,0 +1,21 @@
# Java language servers
Settings › Languages & Frameworks › Language Servers lists Eclipse JDT Language
Server (`jdtls`) and [kmp-lsp](https://github.com/Hessesian/kmp-lsp). Select either
tool and use **Language server for Java** to choose one. The choice can be global
or limited to the current project. **Automatic** prefers jdtls and falls back to
kmp-lsp when jdtls is not installed; **None** disables both.
Install kmp-lsp from that page, or point its executable path at an existing
installation. The IDE downloads a platform distribution containing both kmp-lsp
and its native JAR indexer. Reinstall and uninstall manage both together.
kmp-lsp offers syntax-based Java code insight without running a JVM. It does not
provide JDT's compiler analysis, Lombok integration, or JDT-specific refactorings.
Java Application builds, classpath discovery and the Microsoft Java debugger still
require jdtls; use jdtls for those flows. Maven and Gradle build tasks can run
independently of the selected code-insight server.
This built-in choice applies to Java. Kotlin and Swift keep their existing server
choices. To use another server, add it with **New Language Server**, assign its
languages, and disable the servers it replaces.
Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

File renamed without changes.
+8 -9
View File
@@ -1,6 +1,6 @@
# Theme plugins
OpenIDEA ships seven built-in themes: Islands Light, Islands Dark,
OpenIDEA ships seven [built-in themes](../themes.md): Islands Light, Islands Dark,
High Contrast, Solarized Light, Solarized Dark, One Light and One Dark. Additional
themes come from **theme plugins**, the same model as
IntelliJ IDEA, where most themes are plugins rather than part of the IDE.
@@ -18,7 +18,7 @@ my-theme/
```
The quickest way to start is `openidea-plugin new --template theme`, or a copy of
[`plugins/official/harbor-night`](official/harbor-night).
[`plugins/official/harbor-night`](../../plugins/official/harbor-night).
It contains *Harbor Night*, a complete example dark theme that sets every
themeable token. The rest of this page covers the file format, the token
reference, porting themes from IntelliJ IDEA and VS Code, and testing.
@@ -39,7 +39,7 @@ editor-scheme = "themes/harbor-night.scheme.json" # optional
```
Paths are relative to the package directory and may not leave it. The other
manifest fields are described in [`plugins/README.md`](README.md).
manifest fields are described in [`plugins/README.md`](../../plugins/README.md).
## The theme file
@@ -55,7 +55,7 @@ manifest fields are described in [`plugins/README.md`](README.md).
}
```
- **`tokens`** are the CSS variables of [`src/styles/tokens.css`](../src/styles/tokens.css),
- **`tokens`** are the CSS variables of [`src/styles/tokens.css`](../../src/styles/tokens.css),
the IDE's single source of colours. Components never use colour literals, so
every surface, border, text colour, syntax colour and status colour can be
themed through them. The [reference](#token-reference) lists them all.
@@ -206,7 +206,7 @@ ship no `.icls` at all keep the built-in syntax colours.
| `--bg-toggle-*` | `ToggleButton.onBackground`, `.offBackground`, `.buttonColor` |
| `--bg-feedback-*`, `--border-feedback-*` | `Banner.infoBackground`, `Banner.errorBorderColor`, … |
The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../src/features/plugins/intellijTheme.ts).
The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../../src/features/plugins/intellijTheme.ts).
#### `.icls` → editor scheme
@@ -214,7 +214,7 @@ The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../src/
|---|---|
| `--fg-editor`, `--bg-editor` | attribute `TEXT` (foreground, background) |
| `--caret`, `--bg-current-line` | colour `CARET_COLOR`, `CARET_ROW_COLOR` |
| `--bg-editor-selection`, `--fg-editor-selection` | colour `SELECTION_BACKGROUND`, `SELECTION_FOREGROUND` |
| `--bg-editor-selection` | colour `SELECTION_BACKGROUND` |
| `--fg-line-number`, `--fg-line-number-current` | colour `LINE_NUMBERS_COLOR`, `LINE_NUMBER_ON_CARET_ROW_COLOR` |
| `--bg-editor-gutter` | colour `EDITOR_GUTTER_BACKGROUND`, else the editor background (as the new UI paints it; `GUTTER_BACKGROUND` is the classic UI's) |
| `--border-indent-guide`, `--border-right-margin`, `--fg-whitespace` | colour `INDENT_GUIDE`, `RIGHT_MARGIN_COLOR`, `WHITESPACES` |
@@ -268,7 +268,7 @@ from `type`, then map:
| `--fg-editor` | `editor.foreground` |
| `--caret` | `editorCursor.foreground` |
| `--bg-current-line` | `editor.lineHighlightBackground` |
| `--bg-editor-selection`, `--bg-editor-selection-inactive`, `--fg-editor-selection` | `editor.selectionBackground`, `editor.inactiveSelectionBackground`, `editor.selectionForeground` |
| `--bg-editor-selection`, `--bg-editor-selection-inactive` | `editor.selectionBackground`, `editor.inactiveSelectionBackground` |
| `--fg-line-number`, `--fg-line-number-current` | `editorLineNumber.foreground`, `editorLineNumber.activeForeground` |
| `--border-indent-guide`, `--border-indent-guide-active` | `editorIndentGuide.background1`, `editorIndentGuide.activeBackground1` |
| `--fg-whitespace` | `editorWhitespace.foreground` |
@@ -329,7 +329,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
256 themeable tokens: 125 in the UI theme file, 131 in the editor colour scheme file.
255 themeable tokens: 125 in the UI theme file, 130 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -499,7 +499,6 @@ a token.
| `--border-right-margin` | `#323438` | `#ebecf0` |
| `--bg-editor-selection` | `#214283` | `#a6d2ff` |
| `--bg-editor-selection-inactive` | `#1d3460` | `#c9e3ff` |
| `--fg-editor-selection` | `inherit` | `inherit` |
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
+100
View File
@@ -0,0 +1,100 @@
# Built-in theme sources
OpenIDEA includes Islands Light, Islands Dark, High Contrast, Solarized Light,
Solarized Dark, One Light and One Dark. Select them in Settings › Appearance,
Welcome › Customize or the existing quick theme menu. The same themes are
available as explicit editor schemes; “Theme default” follows the UI theme.
“Sync with OS” uses the existing preferred light/dark theme settings. Existing
ids (`light`, `dark`, `dark-hc`, `system`) and attribute overrides remain valid.
## Upstream palettes and licenses
All sources below are the original authors’ repositories, pinned for reproducibility.
Full MIT notices are preserved in [THIRD_PARTY_LICENSES.md](../THIRD_PARTY_LICENSES.md).
| Theme | Original source revision | Palette / mapping source |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Solarized Light / Dark | [Ethan Schoonover, 62f656a](https://github.com/altercation/solarized/tree/62f656a02f93c5190a8753159e34b385588d5ff3) | README’s sixteen exact sRGB hex colours; `vim-colors-solarized/colors/solarized.vim` semantic roles; iTerm2 ANSI slots |
| One Light syntax | [GitHub / Atom, d845790](https://github.com/atom/one-light-syntax/tree/d84579027410c576086dfca14d934c4bd74b0438) | `styles/colors.less`, `styles/syntax-variables.less` |
| One Dark syntax | [GitHub / Atom, 9c96f44](https://github.com/atom/one-dark-syntax/tree/9c96f4454362267ac45322063e193ccf9d2debb1) | `styles/colors.less`, `styles/syntax-variables.less` |
| One Light UI | [GitHub / Atom, a27bce3](https://github.com/atom/one-light-ui/tree/a27bce35088ecd323e44f5c2510bcf2d4e9efcf8) | `styles/ui-variables.less`, `styles/ui-variables-custom.less` |
| One Dark UI | [GitHub / Atom, 18c2143](https://github.com/atom/one-dark-ui/tree/18c2143d41e17b337fff6dc7024562c4d999be17) | `styles/ui-variables.less`, `styles/ui-variables-custom.less` |
## Mapping into OpenIDEA
`src/platform/themes.ts` registers the stable ids. `data-theme` retains the
light/dark kind for icons, native controls and existing selectors;
`data-color-theme` selects the additional palette. High Contrast continues to
use `data-contrast="high"`. Plugin themes clear the built-in palette attribute
and retain their existing light/dark fallback; removing a plugin restores the
chosen built-in palette.
`src/styles/tokens.css` contains the shared mapping for all colour tokens and
four palette blocks. No new dependency is required. Solarized uses base3/base2
for light backgrounds and base03/base02 for dark backgrounds, base00/base0 for
text, and the original eight accents. Vim’s statements are green, constants
and strings cyan, identifiers blue, types yellow, and comments the original
subdued base1/base01. Solarized’s original ANSI slot order is preserved (the
bright slots intentionally include orange, violet and neutrals).
One retains the exact HSL definitions from Atom, avoiding rounded palette
approximations: 230° / 1% / 98% light and 220° / 13% / 18% dark editor backgrounds;
purple keywords, green strings, blue functions, red variables/tags, orange
numbers and attributes, yellow classes, and the original mono-3 comments.
UI frame/popup levels use Atom’s relative background lightness formulas with
the matching syntax theme. OpenIDEA’s shared UI text uses the syntax foreground
and mono-2 for readable secondary labels. Selection and cursor-line values
follow Atom’s syntax variables. Atom does not define a terminal ANSI palette:
One’s ANSI slots reuse its exact semantic syntax hues, repeating them for bright
colours rather than inventing a different palette. One Light maps ANSI white
and bright white to its mono-2 and mono-1 text colours so they remain visible
on the light terminal background.
Components without an upstream equivalent (feedback banners, tool window states,
search fills and focus decoration) use the original semantic hues, neutrals,
or explicit `color-mix()` tints. This adapts colours to the existing layout;
it does not recreate Atom’s or Vim’s UI. Platform traffic-light colours stay
platform colours. Original subdued comments and some syntax accents have lower
contrast than a high-contrast accessibility theme; High Contrast remains available.
Native windows open in the theme's frame colour before their page loads:
`src-tauri/src/theme_frame.rs` reads `--bg-frame` of each built-in theme from
`tokens.css` itself (resolving `var()` and `hsl()`), so there is no second
colour table to keep in sync. Elsewhere the frontend resolves HSL and
`color-mix()` tokens to RGB (`src/platform/cssColor.ts`) for Tauri's window
API, the native Settings window, Mermaid and the terminal.
`public/boot.js` restores the palette (`data-color-theme`) and the splash's
resolved frame, text and accent colours before the application stylesheet
loads, so a restart in One Dark or Solarized shows no flash of another theme.
The backend's settings persistence remains authoritative. Terminal, Mermaid
and editor-scheme observers also watch palette changes within the same
light/dark kind. Custom accents, explicit editor schemes, per-scheme attribute
overrides, compact mode and accessibility options retain their precedence.
`src/features/settings/__tests__/contrast.test.ts` requires every palette theme
to define every colour token of the base themes (in the shared mapping or its
own block), so a token added to `tokens.css` later can't silently fall back to
an Islands colour.
## Browser evidence
These approved screenshots are reused from the original theme implementation on
0.1.15; regression checks are rerun against the upstream contribution base.
Captured at 1400 × 900 in Linux Chromium 154 using OpenIDEA's public in-memory
`acme-api` project / Kotlin fixture and synthetic ANSI terminal output. The
terminal is a real xterm renderer, without a native PTY or shell. These images
cover the shell, sidebar, editor, gutter, find matches, terminal, Settings,
focused controls, disabled settings and the selected theme popup.
| Theme | Workbench, editor and terminal | Settings and theme popup |
| --------------- | -------------------------------------------------------- | ---------------------------------------------------- |
| Solarized Light | [Screenshot](media/themes/solarized-light-workbench.png) | [Screenshot](media/themes/solarized-light-popup.png) |
| Solarized Dark | [Screenshot](media/themes/solarized-dark-workbench.png) | [Screenshot](media/themes/solarized-dark-popup.png) |
| One Light | [Screenshot](media/themes/one-light-workbench.png) | [Screenshot](media/themes/one-light-popup.png) |
| One Dark | [Screenshot](media/themes/one-dark-workbench.png) | [Screenshot](media/themes/one-dark-popup.png) |
The browser regression also checks refresh / startup cache restoration,
selection visibility, same-kind switches, the quick menu, existing themes,
explicit editor schemes and preferred OS themes. Native startup colours are
covered by Rust unit tests.
+42
View File
@@ -0,0 +1,42 @@
# Open scripts from Unity
OpenIDEA can open scripts and Console source locations without a Unity plugin.
1. In Unity, open **Edit > Preferences > External Tools** (on macOS,
**Unity > Settings/Preferences > External Tools**).
2. Under **External Script Editor**, choose **Browse** and select OpenIDEA's
executable (or its `.app` on macOS).
3. Set **External Script Editor Args** to:
```text
$(ProjectPath) --file $(File) --line $(Line) --column $(Column)
```
Unity 2022.3 quotes the expanded project and file paths itself, including paths
with spaces. Do not add another pair of quotes around these placeholders.
Double-click a script in the Project window or a source location in the Console.
OpenIDEA opens the project and file, or focuses its existing project window, then
places the caret at the supplied location. Other open tabs stay in place.
The project argument makes cold launches open the Unity project rather than a
standalone file. Without it, `--file` opens in an existing matching project window
or in LightEdit. Relative file paths resolve against the supplied project folder,
or against the launch working directory when no project is supplied.
For a direct invocation of the application executable, quote paths as required
by your shell. The separately installed `openidea` command has its own syntax:
```text
/path/to/openidea "/path/to/My Game" --file "/path/to/My Game/Assets/Player.cs" --line 12 --column 5
```
`--file=...`, `--line=...` and `--column=...` also work. Lines and columns are
1-based; Unity's `0` and `-1` mean an unknown position. An omitted or unknown
column uses the start of the line. Without a known line, the file opens without
an explicit caret position. Invalid arguments and missing files report an error.
On Windows, use the same permissions for Unity and OpenIDEA when possible.
If a running instance does not receive a request, restart both applications with
matching permissions and retry. This integration opens local projects; it does
not generate C# project files or attach a debugger.
+15 -19
View File
@@ -61,7 +61,7 @@ const entries: AgentEntry[] = [
for (const theme of ["dark", "light"] as const) {
for (const width of [760, 1400]) {
test(`approval is asked above the composer at ${width}px in ${theme}`, async ({ page }) => {
test(`approval stays inside the agent column at ${width}px in ${theme}`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto("/");
await page.getByRole("tree", { name: "Project" }).waitFor();
@@ -85,47 +85,43 @@ for (const theme of ["dark", "light"] as const) {
await expect(card.locator("pre")).toHaveText(command);
await expect(card.getByRole("button")).toHaveText(["Allow Once", "Always Allow", "Decline", "Decline and Stop"]);
const geometry = await card.evaluate((el) => {
// Asked above the composer, below the conversation.
const area = el.closest('[class*="_composerArea_"]')!;
const transcript = area.parentElement!.querySelector('[class*="_stream_"]')!;
const transcript = el.parentElement!.parentElement!;
const prompt = transcript.querySelector('[class*="_user_"]')!;
const reply = transcript.querySelector('[class*="_agentCard_"]')!;
const rect = el.getBoundingClientRect();
const bounds = area.getBoundingClientRect();
const bounds = transcript.getBoundingClientRect();
return {
scroll: area.scrollWidth,
client: area.clientWidth,
inStream: transcript.contains(el),
scroll: transcript.scrollWidth,
client: transcript.clientWidth,
contained: rect.left >= bounds.left && rect.right <= bounds.right,
below: rect.top >= transcript.getBoundingClientRect().bottom - 1,
promptRadius: parseFloat(getComputedStyle(prompt).borderRadius),
replyBackground: getComputedStyle(reply).backgroundColor,
replyBorder: getComputedStyle(reply).borderTopWidth,
userOnRight: prompt.getBoundingClientRect().right > reply.getBoundingClientRect().right,
approvalAligned: Math.abs(rect.left - reply.getBoundingClientRect().left) < 1,
authorBeforeWork:
transcript.querySelector('[class*="_agentAuthor_"]')?.nextElementSibling?.matches("details") ?? false,
headAligned:
Math.abs(
prompt.getBoundingClientRect().right - prompt.previousElementSibling!.getBoundingClientRect().right,
) < 1,
footerAligned:
Math.abs(prompt.getBoundingClientRect().right - prompt.nextElementSibling!.getBoundingClientRect().right) <
1,
cardWidth: rect.width,
available: bounds.width,
replyFooter: reply.nextElementSibling != null,
actionsVisible: getComputedStyle(reply.nextElementSibling!.querySelector('[class*="_messageActions_"]')!)
.opacity,
};
});
expect(geometry.scroll).toBe(geometry.client);
expect(geometry.inStream).toBe(false);
expect(geometry.contained).toBe(true);
expect(geometry.below).toBe(true);
expect(geometry.promptRadius).toBeLessThanOrEqual(6);
// Replies read as plain text on the panel.
expect(geometry.replyBackground).toBe("rgba(0, 0, 0, 0)");
expect(geometry.replyBorder).toBe("0px");
expect(geometry.userOnRight).toBe(true);
expect(geometry.approvalAligned).toBe(true);
expect(geometry.authorBeforeWork).toBe(true);
expect(geometry.headAligned).toBe(true);
// The turn waits on the approval: its interim message has no footer yet.
expect(geometry.replyFooter).toBe(false);
expect(geometry.footerAligned).toBe(true);
// The last message stays actionable even while an approval follows it.
expect(geometry.actionsVisible).toBe("1");
if (width === 760) expect(geometry.cardWidth).toBeGreaterThan(geometry.available * 0.8);
await card.locator("pre").focus();
for (const label of ["Allow Once", "Always Allow", "Decline", "Decline and Stop"]) {
-2
View File
@@ -278,8 +278,6 @@ test("keeps chat history readable and selections free of timestamps and status l
expect(selected).not.toMatch(/AM|PM|5s|Reasoning|Requesting task context/);
await page.keyboard.press("ControlOrMeta+c");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(selected);
// The finished turn's reasoning is folded with its work.
await stream.locator("[class*='_turnFold_'] > summary").click();
await stream.locator("[class*='_thought_'] > summary").click();
await expect(stream.getByText("Requesting task context", { exact: true })).toBeVisible();
await expect(stream.locator("[class*='_thoughtBody_'] strong")).toHaveText("Requesting task context");
+72 -6
View File
@@ -66,6 +66,76 @@ async function openHistory(page: Page) {
return stream;
}
for (const placement of ["editor", "toolWindow"] as const) {
test(`lets small upward wheel steps leave the end while replies stream in the ${placement}`, async ({ page }) => {
const stream = await openHistory(page);
await page.evaluate(
async ({ id, placement }) => {
const path = "/src/features/agents/store.ts";
const store = await import(path);
const state = store.useAgents.getState();
store.useAgents.setState({
tasks: new Map(state.tasks).set(id, { ...state.tasks.get(id)!, status: "running" }),
});
if (placement === "toolWindow") store.dockTask(id);
},
{ id: task.id, placement },
);
await expect(stream.getByText("Working…", { exact: true })).toBeVisible();
await expect(stream.locator(":scope > *")).toHaveCount(80 * 3 + 1);
await expect.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThan(2);
const timer = await page.evaluate(async (id) => {
const path = "/src/features/agents/store.ts";
const store = await import(path);
const el = [...document.querySelectorAll<HTMLElement>("[class*='_stream_']")].find((el) => el.offsetHeight)!;
el.addEventListener(
"wheel",
() => {
el.dataset.streamWheelStart = String(el.scrollTop);
},
{ capture: true, once: true },
);
let chunks = 0;
return window.setInterval(() => {
const state = store.useAgents.getState();
const entries = state.entries.get(id)!.slice();
const last = entries.at(-1)!;
if (last.type !== "agent") throw new Error("Expected a streaming reply");
entries[entries.length - 1] = { ...last, text: `${last.text}\n\nStreaming chunk ${++chunks}` };
store.useAgents.setState({ entries: new Map(state.entries).set(id, entries) });
el.dataset.streamChunks = String(chunks);
}, 40);
}, task.id);
const chunks = async () => Number(await stream.getAttribute("data-stream-chunks"));
try {
await stream.hover();
for (let i = 0; i < 8; i++) {
const previous = await chunks();
await page.mouse.wheel(0, -12);
await expect.poll(chunks).toBeGreaterThan(previous);
}
const top = await stream.evaluate((el) => el.scrollTop);
expect(Number(await stream.getAttribute("data-stream-wheel-start")) - top).toBeGreaterThan(80);
const previous = await chunks();
await expect.poll(chunks).toBeGreaterThan(previous + 3);
expect(Math.abs((await stream.evaluate((el) => el.scrollTop)) - top)).toBeLessThanOrEqual(1);
const jump = page.getByRole("button", { name: "Jump to Latest" });
await expect(jump).toBeVisible();
await jump.click();
await expect
.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
.toBeLessThan(2);
const resumed = await chunks();
await expect.poll(chunks).toBeGreaterThan(resumed + 3);
await expect
.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
.toBeLessThan(2);
} finally {
await page.evaluate((timer) => window.clearInterval(timer), timer);
}
});
}
for (const selected of [false, true]) {
test(`keeps long-chat scroll geometry stable while jumping through history (selected: ${selected})`, async ({
page,
@@ -112,23 +182,19 @@ for (const selected of [false, true]) {
});
}
test("opens a folded turn with the keyboard and jumps back to the latest reply", async ({ page }) => {
test("opens a folded process with the keyboard and jumps back to the latest reply", async ({ page }) => {
const stream = await openHistory(page);
await stream.evaluate((el) => {
el.scrollTop = 0;
});
const jump = page.getByRole("button", { name: "Jump to Latest" });
await expect(jump).toBeVisible();
const fold = stream.locator("[class*='_turnFold_']").first();
const fold = stream.locator("[class*='_processRun_']").first();
const summary = fold.locator(":scope > summary");
await summary.focus();
await page.keyboard.press("Enter");
await expect(fold).toHaveAttribute("open", "");
const run = fold.locator("[class*='_processRun_']").first();
await expect(run).toBeVisible();
await run.locator(":scope > summary").click();
await expect(fold.getByText("read src/module-0.ts", { exact: true })).toBeVisible();
await summary.focus();
await page.keyboard.press("Space");
await expect(fold).not.toHaveAttribute("open", "");
await jump.focus();
+2 -5
View File
@@ -5,14 +5,14 @@ for (const [id, selector] of [
["editor.inspections", "[class*='split'] > [class*='details']"],
["languages.servers", "[class*='body'] > [class*='details']"],
["plugins", "[class*='body'] > [class*='details']"],
["agents.list", "section[aria-label='Codex']"],
["tools.agents", "section[aria-label='Codex executable']"],
]) {
test(`${id} keeps its details usable beside the list`, async ({ workbench: page }) => {
await page.keyboard.press("Meta+,");
const dialog = page.getByRole("dialog", { name: "Settings", exact: true });
await expect(dialog).toBeVisible();
await page.evaluate(async (id) => {
if (id === "agents.list") {
if (id === "tools.agents") {
const path = "/src/features/agents/registry.ts";
const { tauriCommands } = await import(/* @vite-ignore */ path);
tauriCommands.providers = async () => [
@@ -26,9 +26,6 @@ for (const [id, selector] of [
installed: true,
commandOverridden: false,
env: {},
config: [],
commands: [],
source: { kind: "builtin" },
},
];
}
+3 -3
View File
@@ -251,7 +251,7 @@ its component with `plugins/build.sh crates/ide-plugin/tests/fixtures/file-templ
`[[contributes.documentIndexers]]` declares file `extensions` and requires the optional
`document-indexer` export. It publishes cross-file associations (definitions, references,
aliases and gutter markers) in background batches, not on the editing path. See
[Background document indexers](indexing.md).
[Background document indexers](../docs/plugins/indexing.md).
API 1.3 also uses this graph for Find Usages and Rename on published definitions
and references, alongside language-server results. Name anchors must cover exactly
@@ -1957,7 +1957,7 @@ colours, merged over the theme). Pick a theme in Settings › Plugins › _Apply
Start with `openidea-plugin new <dir> --template theme` (or a copy of
[`official/harbor-night`](official/harbor-night)); the full guide, token
reference and IntelliJ / VS Code porting tables are in
[`plugins/themes.md`](themes.md).
[`docs/plugins/themes.md`](../docs/plugins/themes.md).
## 5. File icon themes
@@ -2134,7 +2134,7 @@ granted, and starts the approved program.
the package at its root or in its only folder, or a folder's `plugin.toml`;
a zip without a `plugin.toml` there, such as an IntelliJ plugin, is refused; IntelliJ
theme plugins are imported instead: _Import IntelliJ Theme from File…_ and _Import Theme
from JetBrains IDE…_, see [`plugins/themes.md`](themes.md)). The install
from JetBrains IDE…_, see [`docs/plugins/themes.md`](../docs/plugins/themes.md)). The install
page lists every capability and whether the package is signed by a publisher
the marketplace trusts; nothing is copied until you accept.
- **Versions:** installed plugins live in `<app-data>/plugins/<id>/<version>/`
+1 -1
View File
@@ -4,7 +4,7 @@ An OpenIDEA theme plugin, **Harbor Night**: a dark theme with slate surfaces, a
teal accent and a warm syntax palette. It is also where new themes start:
`openidea-plugin new --template theme` copies its theme files, or copy this
folder. Full documentation, the token reference and porting guides are in
[`plugins/themes.md`](../../themes.md).
[`docs/plugins/themes.md`](../../../docs/plugins/themes.md).
```
harbor-night/
@@ -79,7 +79,6 @@
"--border-right-margin": "#2a313b",
"--bg-editor-selection": "#2d7772",
"--bg-editor-selection-inactive": "#255854",
"--fg-editor-selection": "inherit",
"--bg-text-search-result": "#114957",
"--bg-text-search-result-current": "#165e70",
"--bg-matched-brace": "#38424f",
@@ -1,7 +1,7 @@
// Harbor Night: UI theme. Keys are the CSS variables of src/styles/tokens.css,
// grouped like its Islands Light block. Delete a line to keep the built-in value
// of the base named by "kind". Comments are allowed (// and /* */).
// Full reference: plugins/themes.md.
// Full reference: docs/plugins/themes.md.
{
"id": "harbor-night",
"name": "Harbor Night",
+1 -1
View File
@@ -105,7 +105,7 @@ dependencies = [
[[package]]
name = "openidea-plugin-openidea-mybatis"
version = "0.2.0"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "openidea-plugin-openidea-mybatis"
version = "0.2.0"
version = "0.1.0"
edition = "2024"
publish = false
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
4ea84cbf4f9202712d8e147dd4631e9d56bf2e324f77de4c67239e9bf5f1f545
e79eafa3cef8de7dc8fb5821c3715ceb36c3d4bac92d3b5ca8aedbfd2ec189a1
+3 -3
View File
@@ -1,10 +1,10 @@
id = "openidea.mybatis"
name = "MyBatis Navigator"
version = "0.2.0"
description = "Jumps between MyBatis mapper interfaces and their XML statements, resultMaps, sql/include fragments and namespaces (gutter arrows and Go to Definition), completes statement and sql ids and #{parameters}."
version = "0.1.0"
description = "Jumps between MyBatis mapper interfaces and their XML statements (gutter arrows and Go to Definition), completes statement ids and #{parameters}."
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.2.0 — namespace, resultMap/extends and sql/include navigation, refid completion; 0.1.0 — first release: navigation both ways, line markers, id and parameter completion."
change-notes = "0.1.0 — first release: navigation both ways, line markers, id and parameter completion."
categories = ["frameworks"]
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
+24 -212
View File
@@ -1,8 +1,7 @@
//! MyBatis Navigator for OpenIDEA: a mapper interface (`com.acme.UserMapper`)
//! and the XML with `<mapper namespace="com.acme.UserMapper">` point at each
//! other: gutter arrows and Go to Definition on the namespace, methods and
//! statement ids, plus `resultMap` / `extends` / `<include refid>` targets,
//! completion of statement ids, `<sql>` ids and `#{parameters}`.
//! other: gutter arrows and Go to Definition on methods and statement ids,
//! completion of statement ids and `#{parameters}`.
use openidea_plugin::providers::{CompletionItem, CompletionKind, Document, LineMarker, Providers};
use openidea_plugin::text::{offset, position};
@@ -25,20 +24,9 @@ pub struct Named {
/// A mapper XML file.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MapperXml {
/// `<mapper namespace="…">` (the value's range).
pub namespace: Option<Named>,
pub namespace: Option<String>,
/// `<select|insert|update|delete id="…">` (the id value's range).
pub statements: Vec<Named>,
/// `<resultMap id="…">` (the id value's range).
pub result_maps: Vec<Named>,
/// `<sql id="…">` (the id value's range).
pub sql_fragments: Vec<Named>,
/// `resultMap="…"` values (on statements, associations, collections…).
pub result_map_refs: Vec<Named>,
/// `extends="…"` values on `<resultMap>`.
pub extends_refs: Vec<Named>,
/// `<include refid="…">` values.
pub include_refs: Vec<Named>,
}
/// A Java interface.
@@ -46,8 +34,6 @@ pub struct MapperXml {
pub struct MapperJava {
/// Fully qualified name.
pub name: Option<String>,
/// The interface's own name (for `<mapper namespace>` targets).
pub decl: Option<Named>,
pub methods: Vec<(Named, Vec<String>)>,
}
@@ -69,42 +55,12 @@ pub fn parse_xml(text: &str) -> MapperXml {
let start = at + i + 1;
at = start;
let name: String = text[start..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
let named = |attr_name: &str| -> Option<Named> {
let (v, s, e) = attr(start, attr_name)?;
Some(Named { name: v, range: range_of(text, s, e) })
};
if name == "mapper" {
out.namespace = named("namespace");
} else if STATEMENTS.contains(&name.as_str()) {
if let Some(id) = named("id") {
out.statements.push(id);
}
if let Some(r) = named("resultMap") {
out.result_map_refs.push(r);
}
} else if name == "resultMap" {
if let Some(id) = named("id") {
out.result_maps.push(id);
}
if let Some(r) = named("extends") {
out.extends_refs.push(r);
}
if let Some(r) = named("resultMap") {
// `<association resultMap="…">` and friends nest inside resultMaps.
out.result_map_refs.push(r);
}
} else if name == "association" || name == "collection" || name == "case" {
if let Some(r) = named("resultMap") {
out.result_map_refs.push(r);
}
} else if name == "sql" {
if let Some(id) = named("id") {
out.sql_fragments.push(id);
}
} else if name == "include"
&& let Some(r) = named("refid")
out.namespace = attr(start, "namespace").map(|(v, ..)| v);
} else if STATEMENTS.contains(&name.as_str())
&& let Some((id, s, e)) = attr(start, "id")
{
out.include_refs.push(r);
out.statements.push(Named { name: id, range: range_of(text, s, e) });
}
}
out
@@ -269,15 +225,11 @@ pub fn parse_java(text: &str) -> MapperJava {
let package =
text.lines().find_map(|l| l.trim().strip_prefix("package ").map(|p| p.trim_end_matches(';').trim().to_owned()));
let Some(i) = text.find("interface ") else { return MapperJava::default() };
let rest = &text[i + 10..];
let indent = rest.len() - rest.trim_start().len();
let simple: String = rest[indent..].chars().take_while(|c| ident(*c)).collect();
let simple: String = text[i + 10..].trim_start().chars().take_while(|c| ident(*c)).collect();
let name = Some(match package {
Some(p) => format!("{p}.{simple}"),
None => simple.clone(),
None => simple,
});
let decl =
Some(Named { name: simple.clone(), range: range_of(text, i + 10 + indent, i + 10 + indent + simple.len()) });
let body_start = text[i..].find('{').map_or(text.len(), |b| i + b + 1);
let mut methods = Vec::new();
let mut depth = 0;
@@ -307,7 +259,7 @@ pub fn parse_java(text: &str) -> MapperJava {
_ => {}
}
}
MapperJava { name, decl, methods }
MapperJava { name, methods }
}
fn project_of(path: &str) -> Option<Project> {
@@ -338,7 +290,7 @@ impl Navigator {
continue;
}
if let Some(ns) = parse_xml(&text).namespace {
map.insert(ns.name, path);
map.insert(ns, path);
}
}
self.xml_index.insert(root.clone(), (Instant::now(), map));
@@ -348,7 +300,7 @@ impl Navigator {
&& let Ok(text) = project.read_file(&path)
{
let xml = parse_xml(&text);
if xml.namespace.as_ref().is_some_and(|n| n.name == namespace) {
if xml.namespace.as_deref() == Some(namespace) {
return Some((path, xml));
}
}
@@ -367,67 +319,22 @@ impl Navigator {
(java.name.as_deref() == Some(namespace)).then_some((path, java))
}
/// Where `resultMap="…"` / `<include refid="…">` points: a `<resultMap id>` /
/// `<sql id>` in this file, or in the mapper a `namespace.id` prefix names
/// (ids never hold dots, so the last segment is the id).
fn anchor_target(
&mut self,
project: &Project,
doc: &Document,
xml: &MapperXml,
here_ns: &str,
name: &str,
sql: bool,
) -> Option<Location> {
let (ns, id) = name.rsplit_once('.').map_or((here_ns, name), |(ns, id)| (ns, id));
let pick = |xml: &MapperXml, path: String| {
let ids = if sql { &xml.sql_fragments } else { &xml.result_maps };
ids.iter().find(|n| n.name == id).map(|n| Location { path, range: n.range })
};
if ns == here_ns {
return pick(xml, doc.path.clone());
}
let (path, other) = self.xml_for(project, ns)?;
pick(&other, path)
}
/// Pairs of (name in this document, target location) for `doc`.
fn links(&mut self, doc: &Document) -> Vec<(Named, Location, String)> {
let Some(project) = project_of(&doc.path) else { return Vec::new() };
if doc.language == "xml" {
let xml = parse_xml(&doc.text);
let Some(ns) = xml.namespace.clone() else { return Vec::new() };
let mut out = Vec::new();
// `<mapper namespace>` → the interface; statement ids → methods.
if let Some((path, java)) = Self::java_for(&project, &ns.name) {
let simple = ns.name.rsplit('.').next().unwrap_or(&ns.name).to_owned();
if let Some(decl) = java.decl {
out.push((
ns.clone(),
Location { path: path.clone(), range: decl.range },
format!("interface {simple}"),
));
}
out.extend(xml.statements.iter().filter_map(|s| {
let Some(ns) = xml.namespace else { return Vec::new() };
let Some((path, java)) = Self::java_for(&project, &ns) else { return Vec::new() };
let simple = ns.rsplit('.').next().unwrap_or(&ns).to_owned();
xml.statements
.into_iter()
.filter_map(|s| {
let (m, _) = java.methods.iter().find(|(m, _)| m.name == s.name)?;
let tip = format!("{simple}.{}()", m.name);
Some((s.clone(), Location { path: path.clone(), range: m.range }, tip))
}));
}
// resultMap / sql references resolve even when the interface is missing.
for r in xml.result_map_refs.iter().chain(xml.extends_refs.iter()) {
if let Some(target) = self.anchor_target(&project, doc, &xml, &ns.name, &r.name, false) {
out.push((r.clone(), target, format!("resultMap {}", r.name)));
}
}
for r in &xml.include_refs {
if let Some(target) = self.anchor_target(&project, doc, &xml, &ns.name, &r.name, true) {
out.push((r.clone(), target, format!("sql {}", r.name)));
}
}
// Gutter arrows read top-down: the namespace marker precedes the statements'.
out.sort_by_key(|a| a.0.range.start);
out
Some((s, Location { path: path.clone(), range: m.range }, tip))
})
.collect()
} else {
let java = parse_java(&doc.text);
let Some(name) = java.name else { return Vec::new() };
@@ -473,7 +380,7 @@ impl Providers for Navigator {
let Some(cursor) = offset(&doc.text, at) else { return Vec::new() };
let before = &doc.text[..cursor];
let xml = parse_xml(&doc.text);
let Some(ns) = xml.namespace.clone().map(|n| n.name) else { return Vec::new() };
let Some(ns) = xml.namespace.clone() else { return Vec::new() };
let Some(project) = project_of(&doc.path) else { return Vec::new() };
let Some((_, java)) = Self::java_for(&project, &ns) else { return Vec::new() };
let line_start = before.rfind('\n').map_or(0, |i| i + 1);
@@ -496,24 +403,6 @@ impl Providers for Navigator {
})
.collect();
}
// Inside `refid="…` of an `<include>`: this file's `<sql>` ids.
if let Some(i) = line.rfind("refid=\"")
&& !line[i + 7..].contains('"')
&& line.contains("<include")
{
let typed = &line[i + 7..];
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
return xml
.sql_fragments
.iter()
.filter(|s| s.name.starts_with(typed))
.map(|s| CompletionItem {
replace: Some(Range::new(start, at)),
detail: Some("sql".into()),
..CompletionItem::new(&s.name, CompletionKind::Variable)
})
.collect();
}
// After `#{`: the enclosing statement's parameters.
if let Some(i) = before.rfind("#{")
&& !before[i..].contains('}')
@@ -569,7 +458,7 @@ mod tests {
);
assert_eq!(java.methods[0].0.range, Range::new(Position::new(5, 9), Position::new(5, 17)));
let xml = parse_xml(XML);
assert_eq!(xml.namespace.as_ref().map(|n| n.name.as_str()), Some("com.acme.UserMapper"));
assert_eq!(xml.namespace.as_deref(), Some("com.acme.UserMapper"));
assert_eq!(xml.statements.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["findById", "insert", ""]);
assert_eq!(xml.statements[0].range, Range::new(Position::new(2, 14), Position::new(2, 22)));
}
@@ -683,9 +572,7 @@ mod tests {
assert_eq!(to_java.len(), 1);
assert_eq!((to_java[0].path.ends_with("UserMapper.java"), to_java[0].range.start), (true, Position::new(7, 8)));
assert!(n.definition(&xml, Position::new(3, 5)).is_empty(), "not on an id");
let markers = n.line_markers(&xml);
assert_eq!(markers[0].tooltip, "Go to interface UserMapper");
assert_eq!(markers[1].tooltip, "Go to UserMapper.findById()");
assert_eq!(n.line_markers(&xml)[0].tooltip, "Go to UserMapper.findById()");
}
#[test]
@@ -699,81 +586,6 @@ mod tests {
assert_eq!(names, [("findAll", vec!["org".to_owned()]), ("count", Vec::<String>::new())]);
}
const REFS_XML: &str = concat!(
"<?xml version=\"1.0\"?>\n",
"<mapper namespace=\"com.acme.OrderMapper\">\n",
" <resultMap id=\"orderMap\" type=\"Order\">\n",
" <association property=\"user\" resultMap=\"userMap\"/>\n",
" </resultMap>\n",
" <resultMap id=\"fullMap\" extends=\"orderMap\"/>\n",
" <sql id=\"baseColumns\">id, total</sql>\n",
" <select id=\"find\" resultMap=\"com.acme.OrderMapper.fullMap\">\n",
" select <include refid=\"baseColumns\"/> from orders\n",
" </select>\n",
"</mapper>\n"
);
#[test]
fn parses_result_maps_sql_and_refs() {
let xml = parse_xml(REFS_XML);
assert_eq!(xml.namespace.as_ref().map(|n| n.name.as_str()), Some("com.acme.OrderMapper"));
fn names(v: &[Named]) -> Vec<&str> {
v.iter().map(|n| n.name.as_str()).collect()
}
assert_eq!(names(&xml.result_maps), ["orderMap", "fullMap"]);
assert_eq!(names(&xml.sql_fragments), ["baseColumns"]);
assert_eq!(names(&xml.result_map_refs), ["userMap", "com.acme.OrderMapper.fullMap"]);
assert_eq!(names(&xml.extends_refs), ["orderMap"]);
assert_eq!(names(&xml.include_refs), ["baseColumns"]);
// `resultMap="userMap"` value on line 3 (0-based).
assert_eq!(xml.result_map_refs[0].range, Range::new(Position::new(3, 44), Position::new(3, 51)));
let java = parse_java("package com.acme;\npublic interface OrderMapper {\n}\n");
assert_eq!(java.name.as_deref(), Some("com.acme.OrderMapper"));
assert_eq!(
java.decl.map(|d| (d.name, d.range)),
Some(("OrderMapper".to_owned(), Range::new(Position::new(1, 17), Position::new(1, 28))))
);
}
#[test]
fn navigates_namespace_result_maps_and_includes() {
let host = MockHost::install();
host.add_file(
"src/main/java/com/acme/OrderMapper.java",
"package com.acme;\npublic interface OrderMapper {\n}\n",
);
host.add_file("src/main/resources/mapper/OrderMapper.xml", REFS_XML);
host.add_file("pom.xml", "<project/>");
let mut n = Navigator::default();
let xml = doc("/project/src/main/resources/mapper/OrderMapper.xml", "xml", REFS_XML);
// `<mapper namespace>` → the interface; unresolvable refs link nowhere.
let to_iface = n.definition(&xml, Position::new(1, 25));
assert_eq!(to_iface.len(), 1);
assert_eq!(
(to_iface[0].path.ends_with("OrderMapper.java"), to_iface[0].range.start),
(true, Position::new(1, 17))
);
assert!(n.definition(&xml, Position::new(3, 40)).is_empty(), "userMap lives in no indexed file");
// `extends="orderMap"` → the parent resultMap; `refid` → the `<sql>` id.
let to_parent = n.definition(&xml, Position::new(5, 37));
assert_eq!(to_parent.len(), 1);
assert_eq!(to_parent[0].range.start, Position::new(2, 17));
let to_sql = n.definition(&xml, Position::new(8, 30));
assert_eq!(to_sql.len(), 1);
assert_eq!(to_sql[0].range.start, Position::new(6, 11));
// `namespace.id` form resolves within the same file.
let to_full = n.definition(&xml, Position::new(7, 45));
assert_eq!(to_full.len(), 1);
assert_eq!(to_full[0].range.start, Position::new(5, 17));
// `<include refid="` completes this file's `<sql>` ids.
let items = n.completion(&xml, Position::new(8, 29), None);
assert_eq!(items.iter().map(|c| c.label.as_str()).collect::<Vec<_>>(), ["baseColumns"]);
}
#[test]
fn completes_ids_and_parameters() {
let _host = setup();
+4 -6
View File
@@ -90,13 +90,11 @@
if (target?.tagName === "SCRIPT" || (target?.tagName === "LINK" && target.rel === "modulepreload")) {
show(`Could not load startup script: ${target.src || target.href}`);
} else if (event.error || event.message) {
// ResizeObserver defers undelivered notifications to the next frame; startup can
// continue. Blink reports the loop as a bare message, WebKit (Tauri on Linux) as an
// Error object — ignore both. No genuine startup failure carries these engine strings.
const message = event.error?.message ?? event.message;
// ResizeObserver defers undelivered notifications to the next frame; startup can continue.
if (
message === "ResizeObserver loop completed with undelivered notifications." ||
message === "ResizeObserver loop limit exceeded"
!event.error &&
(event.message === "ResizeObserver loop completed with undelivered notifications." ||
event.message === "ResizeObserver loop limit exceeded")
)
return;
show(event.error || `${event.message}\n${event.filename || ""}:${event.lineno || 0}:${event.colno || 0}`);
+2 -2
View File
@@ -1,12 +1,12 @@
/**
* Regenerates the token reference in plugins/themes.md from
* Regenerates the token reference in docs/plugins/themes.md from
* src/styles/tokens.css (`pnpm gen:theme-docs`). scripts/theme/theme.test.ts
* fails when the checked-in reference is stale.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { referenceMarkdown, themeTokenGroups, withReference } from "./tokens.ts";
const DOC = "plugins/themes.md";
const DOC = "docs/plugins/themes.md";
const groups = themeTokenGroups(readFileSync("src/styles/tokens.css", "utf8"));
const doc = readFileSync(DOC, "utf8");
const next = withReference(doc, referenceMarkdown(groups));
+1 -1
View File
@@ -59,5 +59,5 @@ if (scheme) {
console.log(
`${theme.name} (${theme.kind}): ${Object.keys(theme.tokens).length} UI tokens` +
(scheme ? `, ${Object.keys(scheme.tokens).length} editor tokens` : "") +
` → ${values.out}\nUnmapped tokens keep the built-in ${theme.kind} values; see plugins/themes.md to fill them in.`,
` → ${values.out}\nUnmapped tokens keep the built-in ${theme.kind} values; see docs/plugins/themes.md to fill them in.`,
);
+2 -13
View File
@@ -40,8 +40,8 @@ describe("theme token reference", () => {
expect(() => themeTokenGroups(bad)).toThrow(/--b .*section comment/);
});
it("is current in plugins/themes.md (run pnpm gen:theme-docs)", () => {
const doc = read("plugins/themes.md");
it("is current in docs/plugins/themes.md (run pnpm gen:theme-docs)", () => {
const doc = read("docs/plugins/themes.md");
expect(withReference(doc, referenceMarkdown(groups))).toBe(doc);
});
});
@@ -159,17 +159,6 @@ describe("IntelliJ theme import", () => {
expect(s).toBeNull();
});
it("maps the selection foreground colour (#2446)", () => {
const s = convertIcls(`<scheme name="Sel" version="142" parent_scheme="Default"><colors>
<option name="SELECTION_BACKGROUND" value="214283" />
<option name="SELECTION_FOREGROUND" value="112233" />
</colors></scheme>`);
expect(s.tokens).toMatchObject({
"--bg-editor-selection": "#214283",
"--fg-editor-selection": "#112233",
});
});
it("only targets tokens that exist in tokens.css", () => {
const declared = new Set([...css.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((m) => m[1]!));
// Read with a fallback by the editor (no tokens.css default), still themeable.
+1 -1
View File
@@ -131,7 +131,7 @@ export function tokenSets(groups: readonly TokenGroup[]): { ui: string[]; scheme
const cell = (v: string) => "`" + v.replace(/\|/g, "\\|") + "`";
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
/** Markdown token reference (one table per group) for plugins/themes.md. */
/** Markdown token reference (one table per group) for docs/plugins/themes.md. */
export function referenceMarkdown(groups: readonly TokenGroup[]): string {
const { ui, scheme } = tokenSets(groups);
const parts = [
+1 -1
View File
@@ -33,7 +33,7 @@ and references. Existing indexers work without another export; declare
cover exactly the name, using UTF-16 columns. The host merges native results,
previews indexed renames and applies them as one undoable workspace edit.
The existing `project.read` capability and trusted-project boundary apply.
See [the indexing contract](../../../plugins/indexing.md#find-usages-and-rename-api-13)
See [the indexing contract](../../../docs/plugins/indexing.md#find-usages-and-rename-api-13)
and [the minimal fixture](../../../crates/ide-plugin/tests/fixtures/indexer).
API 1.3 also adds diagnostics for unsaved documents. Declare
+2 -2
View File
@@ -1,4 +1,4 @@
//! Agents › MCP Server: runs `ide_mcp::McpServer` over built-in host tools
//! Tools › MCP Server: runs `ide_mcp::McpServer` over built-in host tools
//! while `mcpServer.enabled` is on, and reports where it listens.
use crate::state::AppState;
@@ -63,7 +63,7 @@ pub fn stop(state: &AppState) {
}
}
/// What Settings › Agents › MCP Server shows.
/// What Settings › Tools › MCP Server shows.
#[derive(Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct McpServerStatus {
+1 -1
View File
@@ -21,7 +21,7 @@ pub struct AppState {
pub plugins: Arc<ide_host::plugins::PluginHost>,
/// The built-in web server (Open In › Browser, the editor's browser bar).
pub web: ide_web::WebServer,
/// Agents › MCP Server: built-in IDE tools for agents.
/// Tools › MCP Server: built-in IDE tools for agents.
pub mcp: crate::commands::mcp::McpService,
/// Credential prompts of `ssh` / `git` child processes (`None` if it failed to start).
pub askpass: Option<ide_ssh::askpass::AskpassServer>,
+10 -79
View File
@@ -1657,11 +1657,6 @@ export type AgentEvent =
{ type: "truncated"; task: string; length: number } |
/** Entry `index` of `task` (of its subagent `subagent`) was added or replaced. */
{ type: "entry"; task: string; subagent: string | null; index: number; entry: AgentEntry } |
/**
* Text was appended to entry `index` (an agent message or a thought): it read the first
* `from` UTF-16 units of its text when the previous event for it was sent.
*/
{ 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[] } |
/** The installed or configured agent providers changed. */
@@ -1785,11 +1780,8 @@ export type AgentRetry = {
error: string | null,
};
/** Settings › Agents: what the conversation shows and how messages are sent. */
export type AgentSettings = AgentSettings_Serialize | AgentSettings_Deserialize;
/** Settings › Agents: what the conversation shows and how messages are sent. */
export type AgentSettings_Deserialize = {
/** What the meters in an agent task's header show. */
export type AgentSettings = {
/** Context window use. */
showContextUsage?: boolean,
/** Time the agent spent on the task. */
@@ -1798,50 +1790,6 @@ export type AgentSettings_Deserialize = {
showUsageLimits?: boolean,
/** What the session has cost so far, as the agent reports it. */
showCost?: boolean,
/** A finished turn's work folds into one line under its message, leaving the reply. */
foldTurns?: boolean,
/** Streaming text is revealed at a steady pace rather than chunk by chunk. */
smoothText?: boolean,
thinking?: ThinkingDisplay,
toolCalls?: ToolCallDisplay,
/** A tool call's output (a command's, a diff) is shown without opening it. */
expandToolOutput?: boolean,
sendWith?: SendKey,
/**
* What sending does while the agent works: queue the message for after the turn, or
* send it into the turn.
*/
whileRunning?: WhileRunning,
/** Settings new tasks of an agent start with: agent id → option id → value. */
defaults?: { [key in string]: { [key in string]: string } },
};
/** Settings › Agents: what the conversation shows and how messages are sent. */
export type AgentSettings_Serialize = {
/** Context window use. */
showContextUsage: boolean,
/** Time the agent spent on the task. */
showWorkTime: boolean,
/** The account's usage-limit windows (Claude's 5-hour and weekly limits). */
showUsageLimits: boolean,
/** What the session has cost so far, as the agent reports it. */
showCost: boolean,
/** A finished turn's work folds into one line under its message, leaving the reply. */
foldTurns: boolean,
/** Streaming text is revealed at a steady pace rather than chunk by chunk. */
smoothText: boolean,
thinking: ThinkingDisplay,
toolCalls: ToolCallDisplay,
/** A tool call's output (a command's, a diff) is shown without opening it. */
expandToolOutput: boolean,
sendWith: SendKey,
/**
* What sending does while the agent works: queue the message for after the turn, or
* send it into the turn.
*/
whileRunning: WhileRunning,
/** Settings new tasks of an agent start with: agent id → option id → value. */
defaults: { [key in string]: { [key in string]: string } },
};
/** Where an agent comes from. */
@@ -6046,13 +5994,13 @@ export type MavenSettings = {
activeProfiles?: string,
};
/** Agents › MCP Server: agents use the IDE's built-in tools. */
/** Tools › MCP Server: agents use the IDE's built-in tools. */
export type McpServerSettings = {
/** Serve the tools on 127.0.0.1 while the IDE runs. */
enabled?: boolean,
};
/** What Settings › Agents › MCP Server shows. */
/** What Settings › Tools › MCP Server shows. */
export type McpServerStatus = {
running: boolean,
/** Why it isn't running though turned on. */
@@ -7951,9 +7899,6 @@ export type SemanticTokens = {
tokenModifiers: string[],
};
/** The key that sends a message; the other one starts a new line. */
export type SendKey = "enter" | "modEnter";
/** A running language server instance. */
export type ServerId = number;
@@ -8090,9 +8035,9 @@ export type Settings_Deserialize = {
/** Build, Execution, Deployment › Run Targets. */
runTargets?: RunTarget[],
cmake?: CMakeSettings_Deserialize,
/** Settings › Agents. */
agents?: AgentSettings_Deserialize,
/** Agents › MCP Server (global only). */
/** Tools › Agents › Chat. */
agents?: AgentSettings,
/** Tools › MCP Server (global only). */
mcpServer?: McpServerSettings,
/** Plugin settings (`[contributes.settings]`): plugin id → its values by key. */
plugins?: { [key in string]: { [key in string]: unknown } },
@@ -8152,9 +8097,9 @@ export type Settings_Serialize = {
/** Build, Execution, Deployment › Run Targets. */
runTargets: RunTarget[],
cmake: CMakeSettings_Serialize,
/** Settings › Agents. */
agents: AgentSettings_Serialize,
/** Agents › MCP Server (global only). */
/** Tools › Agents › Chat. */
agents: AgentSettings,
/** Tools › MCP Server (global only). */
mcpServer: McpServerSettings,
/** Plugin settings (`[contributes.settings]`): plugin id → its values by key. */
plugins: { [key in string]: { [key in string]: unknown } },
@@ -8728,11 +8673,6 @@ export type ThemeDef = {
export type ThemeKind = "dark" | "light";
/** How the agent's reasoning shows. */
export type ThinkingDisplay =
/** Open while it streams, folded once it ends. */
"auto" | "expanded" | "collapsed";
export type ThreadInfo = {
id: number,
name: string,
@@ -8780,13 +8720,6 @@ export type TomcatInstallation_Serialize = {
tomee: boolean,
};
/** How tool calls between messages show. */
export type ToolCallDisplay =
/** A run of calls is one line saying what they did ("Read 4 files · Edited 2 files"). */
"grouped" |
/** Each call on its own line. */
"each";
/** UI-facing state of one tool. */
export type ToolInfo = {
/** Server / adapter name from `languages/*.toml`. */
@@ -9133,8 +9066,6 @@ export type WebReferenceKind =
/** `var(--name)`: the custom property's declarations. */
"cssVariable";
export type WhileRunning = "queue" | "steer";
/** A request that the invoking window acknowledges through plugin_answer_command. */
export type WindowRequest = WindowRequest_Serialize | WindowRequest_Deserialize;
+6 -4
View File
@@ -70,7 +70,8 @@ import { copyReferenceToClipboard, declaredName, qualifiedReference, referenceOf
import { declarationChain } from "./view/outline";
import { recordClipboard } from "./clipboardHistory";
import { useDocMeta, useOutline } from "./model/document";
import { resetAllEditorsFontZoom, stepAllEditorsFontZoom } from "./cm/prefs";
import { fontZoom, setFontZoom, stepFontZoom } from "./cm/prefs";
import { useEditorRegistry } from "@/editor/api";
import { useProject } from "@/platform/project";
import { call, commands } from "@/platform/ipc";
import { editorPrefs } from "@/platform/editorSettings";
@@ -120,7 +121,7 @@ function stepSurfaceFontSize(delta: 1 | -1): void {
if (cmEl) {
const cmView = EditorView.findFromDOM(cmEl);
if (cmView) {
stepAllEditorsFontZoom(cmView, delta);
stepFontZoom(cmView, delta);
return;
}
}
@@ -128,7 +129,7 @@ function stepSurfaceFontSize(delta: 1 | -1): void {
const slot = activeSlot();
if (!slot?.view) return;
stepAllEditorsFontZoom(slot.view, delta);
stepFontZoom(slot.view, delta);
if (!slot.view.hasFocus && !getContext().popupOpen) slot.view.focus();
}
@@ -897,7 +898,8 @@ export function registerEditorActions(): () => void {
keywords: ["zoom", "font size", "mouse wheel"],
when: "projectOpen",
run: () => {
resetAllEditorsFontZoom();
for (const ed of useEditorRegistry.getState().editors)
if (ed.view.state.field(fontZoom, false)) ed.view.dispatch({ effects: setFontZoom.of(0) });
if (typeof document !== "undefined") {
resetSurfaceFontZoom();
}
+3 -34
View File
@@ -36,7 +36,6 @@ import {
import { markdownImages } from "./markdownImages";
import { selectionMarks } from "./selectionMarks";
import { seamlessSelection } from "./selectionLayer";
import { useEditorRegistry } from "@/editor/api";
import type { EditorExtras } from "@/platform/editorSettings";
import { openideaHighlightStyle } from "./highlight";
import {
@@ -375,16 +374,10 @@ const richCopy = EditorView.domEventHandlers({
// --- caret / scrolling / mouse ----------------------------------------------
/**
* Font zoom new editors start from. ⌘/Ctrl+wheel keeps every open editor on the
* same value (#2480), so editors opened afterwards match the others.
*/
let sharedFontZoom = 0;
/** Font zoom of one editor (⌘ + wheel), in px relative to the configured size. */
export const setFontZoom = StateEffect.define<number>();
export const fontZoom = StateField.define<number>({
create: () => sharedFontZoom,
create: () => 0,
update: (v, tr) => tr.effects.reduce((acc, e) => (e.is(setFontZoom) ? e.value : acc), v),
// Highest precedence: its style comes after the configured font size, which it scales.
provide: (f) =>
@@ -407,35 +400,11 @@ export function stepFontZoom(view: EditorView, delta: number): void {
if (next !== z) view.dispatch({ effects: setFontZoom.of(next) });
}
/**
* Steps the font zoom of every open editor to the same value, so ⌘/Ctrl+wheel
* resizes all editor areas together (#2480) instead of only the focused one.
*/
export function stepAllEditorsFontZoom(origin: EditorView, delta: number): void {
const current = origin.state.field(fontZoom, false);
if (current === undefined) return;
const next = Math.max(-6, Math.min(36, current + delta));
if (next === current) return;
sharedFontZoom = next;
for (const ed of useEditorRegistry.getState().editors) {
if (ed.view.state.field(fontZoom, false) === undefined) continue;
if (ed.view.state.field(fontZoom) !== next) ed.view.dispatch({ effects: setFontZoom.of(next) });
}
}
/** Resets every open editor's font zoom, and the value new editors start from. */
export function resetAllEditorsFontZoom(): void {
sharedFontZoom = 0;
for (const ed of useEditorRegistry.getState().editors)
if (ed.view.state.field(fontZoom, false)) ed.view.dispatch({ effects: setFontZoom.of(0) });
}
/** Exported for tests: the ⌘/Ctrl+wheel handler of the prefs compartment. */
export const wheelZoom = EditorView.domEventHandlers({
const wheelZoom = EditorView.domEventHandlers({
wheel(e, view) {
if (!e.metaKey && !e.ctrlKey) return false;
e.preventDefault();
stepAllEditorsFontZoom(view, e.deltaY < 0 ? 1 : -1);
stepFontZoom(view, e.deltaY < 0 ? 1 : -1);
return true;
},
});
-2
View File
@@ -38,8 +38,6 @@ const plugin = ViewPlugin.fromClass(
const theme = EditorView.baseTheme({
".cm-openidea-selected": {
backgroundColor: "var(--editor-selection-inactive, var(--bg-editor-selection-inactive))",
// Unset (the default `inherit`): the declaration drops and the syntax colours show through.
color: "var(--fg-editor-selection)",
},
"&.cm-focused .cm-openidea-selected": {
backgroundColor: "var(--editor-selection, var(--bg-editor-selection))",
-77
View File
@@ -1,77 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { useEditorRegistry, type TextEditorHandle } from "@/editor/api";
import { fontZoom, resetAllEditorsFontZoom, setFontZoom, stepAllEditorsFontZoom, wheelZoom } from "./cm/prefs";
const zoomOf = (view: EditorView) => view.state.field(fontZoom, false);
describe("shared editor font zoom (#2480)", () => {
const handles: TextEditorHandle[] = [];
const makeView = (extensions = [fontZoom, wheelZoom]) => {
const parent = document.createElement("div");
document.body.append(parent);
const view = new EditorView({ parent, state: EditorState.create({ extensions }) });
const handle = { view } as TextEditorHandle;
useEditorRegistry.getState().editors.add(handle);
handles.push(handle);
return view;
};
beforeEach(() => resetAllEditorsFontZoom());
afterEach(() => {
for (const handle of handles) {
useEditorRegistry.getState().editors.delete(handle);
handle.view.destroy();
handle.view.dom.parentElement?.remove();
}
handles.length = 0;
resetAllEditorsFontZoom();
});
it("zooms every open editor from ⌘/Ctrl+wheel on one of them", () => {
const first = makeView();
const second = makeView();
first.contentDOM.dispatchEvent(new WheelEvent("wheel", { bubbles: true, ctrlKey: true, deltaY: -10 }));
expect(zoomOf(first)).toBe(1);
expect(zoomOf(second)).toBe(1);
second.contentDOM.dispatchEvent(new WheelEvent("wheel", { bubbles: true, ctrlKey: true, deltaY: 10 }));
expect(zoomOf(first)).toBe(0);
expect(zoomOf(second)).toBe(0);
});
it("ignores the wheel without ⌘/Ctrl", () => {
const first = makeView();
makeView();
first.contentDOM.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -10 }));
expect(zoomOf(first)).toBe(0);
});
it("keeps the shared zoom within -6..36", () => {
const first = makeView();
const second = makeView();
first.dispatch({ effects: setFontZoom.of(36) });
stepAllEditorsFontZoom(first, 1);
expect(zoomOf(first)).toBe(36);
expect(zoomOf(second)).toBe(0);
stepAllEditorsFontZoom(first, -1);
expect(zoomOf(first)).toBe(35);
expect(zoomOf(second)).toBe(35);
});
it("leaves views without the zoom field alone", () => {
const first = makeView();
const plain = makeView([]);
stepAllEditorsFontZoom(first, 1);
expect(zoomOf(first)).toBe(1);
expect(zoomOf(plain)).toBeUndefined();
});
it("seeds newly opened editors and resets them all", () => {
const first = makeView();
stepAllEditorsFontZoom(first, 2);
expect(zoomOf(makeView())).toBe(2);
resetAllEditorsFontZoom();
expect(zoomOf(first)).toBe(0);
expect(zoomOf(makeView())).toBe(0);
});
});
-288
View File
@@ -1,288 +0,0 @@
/* The agent list beside the selected agent, as in Settings › Plugins. */
.split {
display: flex;
flex: 1;
min-width: 0;
min-height: 420px;
border-radius: var(--radius-small);
box-shadow: inset 0 0 0 1px var(--border);
overflow: hidden;
}
.master {
display: flex;
flex: 0 1 240px;
flex-direction: column;
min-width: 150px;
min-height: 0;
border-right: 1px solid var(--border);
}
.search {
margin: 8px;
}
.list {
flex: 1;
min-height: 0;
padding: 0 4px 4px;
}
.listSection {
display: flex;
align-items: flex-end;
height: 100%;
padding: 0 8px 4px;
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.agentRow {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
padding: 0 8px;
}
.agentName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agentMissing .agentName {
color: var(--fg-muted);
}
.tag {
flex: none;
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.tagAccent {
color: var(--accent);
}
.empty {
flex: 1;
margin: 0;
padding: 16px;
color: var(--fg-muted);
font-size: var(--fs-small);
text-align: center;
}
.registryState {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-top: 1px solid var(--border);
color: var(--fg-muted);
font-size: var(--fs-small);
overflow-wrap: anywhere;
}
.details {
flex: 1;
min-width: 200px;
padding: 12px 16px 16px;
overflow: auto;
}
.header {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.heading {
flex: 1;
min-width: 0;
}
.heading h3 {
margin: 0;
color: var(--fg-strong);
font-size: var(--fs-ui);
font-weight: 600;
}
.facts {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
margin-top: 2px;
color: var(--fg-muted);
font-size: var(--fs-small);
}
.facts > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--fg-subtle);
}
.dotFound {
background: var(--success);
}
.dotCustom {
background: var(--accent);
}
.actions {
display: flex;
flex: none;
gap: 6px;
}
.progress {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 360px;
margin: 10px 0 0 34px;
}
.notice,
.description,
.warning,
.links,
.error {
margin: 10px 0 0 34px;
overflow-wrap: anywhere;
}
.notice {
color: var(--accent);
font-size: var(--fs-small);
}
.description {
color: var(--fg);
}
.warning {
color: var(--warning);
font-size: var(--fs-small);
}
.registryState .warning,
.registryState .error {
margin: 0;
}
.error {
color: var(--danger);
font-size: var(--fs-small);
white-space: pre-wrap;
}
.links {
display: flex;
gap: 12px;
}
.link {
padding: 0;
border: none;
background: none;
color: var(--link);
font: inherit;
font-size: var(--fs-small);
cursor: pointer;
}
.link:hover {
text-decoration: underline;
}
.thirdParty {
margin: 20px 0 0;
color: var(--fg-subtle);
font-size: var(--fs-small);
}
/* IDEA titled separator: "Launch ─────────", its rows indented under it. */
.section {
margin-top: 18px;
}
.sectionTitle {
display: flex;
align-items: center;
gap: 8px;
height: 22px;
margin-bottom: 4px;
color: var(--fg-strong);
}
.sectionRule {
flex: 1;
height: 1px;
background: var(--border);
}
.sectionBody {
padding-left: 18px;
}
.sectionBody > .error {
margin-left: 0;
}
.text {
margin: 4px 0;
color: var(--fg);
overflow-wrap: anywhere;
}
.text code,
.launch {
font-family: var(--font-mono);
}
.text code {
color: var(--fg-strong);
}
/* "Label: control" rows; a hint sits under its control. */
.form {
display: grid;
/* Wide enough for the longest label, so the sections' controls line up. */
grid-template-columns: minmax(150px, max-content) minmax(0, 1fr);
align-items: center;
column-gap: 10px;
row-gap: 4px;
min-width: 0;
}
.label {
color: var(--fg);
white-space: nowrap;
}
.label::after {
content: ":";
}
.control {
display: flex;
align-items: center;
min-width: 0;
min-height: 28px;
}
.rowHint {
grid-column: 2;
margin: 0 0 6px;
color: var(--fg-subtle);
font-size: var(--fs-small);
overflow-wrap: anywhere;
}
.form > .rowHint:last-child {
margin-bottom: 0;
}
.field {
display: flex;
flex: 1;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 480px;
}
.field > :first-child {
flex: 1;
min-width: 0;
}
.select {
display: flex;
width: 280px;
max-width: 100%;
min-width: 0;
}
/* Every select of a form is as wide as the others, as IDEA lays out combo boxes. */
.select > button {
flex: 1;
min-width: 0;
}
.launch {
min-width: 0;
overflow: hidden;
color: var(--fg-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
-756
View File
@@ -1,756 +0,0 @@
/**
* Settings › Agents › Coding Agents: one searchable list of every agent, those this machine can run first
* (built in, installed from the ACP Registry, or defined in an agent file), then those it
* can install. The selected agent's page holds its actions, the settings its new tasks
* start with, and how it is launched. Launch edits are staged until Apply; installs run in
* the background and keep running after the page closes.
*/
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import type { AgentConfigOption, AgentProvider, AgentRegistry, RegistryAgent, RequestId } from "@/bindings";
import { isCancelled, nextRequestId } from "@/platform/ipc";
import { tx, useTxTemplate } from "@/platform/i18n";
import { openExternalUrl } from "@/platform/openUrl";
import clsx from "clsx";
import { Button, Icon, IconButton, Input, List, ProgressBar, SearchField, Select, Spinner, T } from "@/ui";
import { useSettingsForm } from "@/workbench/contributions";
import { confirm } from "@/workbench/messages";
import { providerIcon } from "./model";
import {
DISTRIBUTION_LABEL,
afterInstall,
afterRemove,
matchesQuery,
sortAgents,
type CommandsBackend,
type RegistryBackend,
} from "./registry";
import styles from "./AgentList.module.css";
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
/** How often the page checks on installs it did not start. */
const BACKGROUND_POLL_MS = 1500;
/** An agent of the list: one this machine knows (`provider`), one of the registry, or both. */
interface Agent {
id: string;
name: string;
provider: AgentProvider | null;
registry: RegistryAgent | null;
}
type Row = { section: string } | Agent;
const isSection = (r: Row): r is { section: string } => "section" in r;
interface Job {
id: string;
/** Absent for an install this page did not start (stopped from the background tasks). */
request: RequestId | null;
fraction: number | null;
message: string;
}
const runnable = (a: Agent) =>
a.provider
? a.provider.installed || a.provider.source.kind !== "builtin" || a.provider.commandOverridden
: a.registry?.installed != null;
function matches(a: Agent, query: string): boolean {
if (a.registry) return matchesQuery(a.registry, query);
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const text = `${a.name} ${a.id}`.toLowerCase();
return words.every((w) => text.includes(w));
}
/**
* The list's rows: runnable agents in the order the backend gives them, then the ones that
* can be installed (built-ins not found first, then the registry's), each under a header.
*/
export function agentRows(providers: AgentProvider[], registry: RegistryAgent[], query: string): Row[] {
const byId = new Map(registry.map((r) => [r.id, r]));
const known = new Set(providers.map((p) => p.id));
const all: Agent[] = [
...providers.map((p) => ({ id: p.id, name: p.name, provider: p, registry: byId.get(p.id) ?? null })),
...sortAgents(registry.filter((r) => !known.has(r.id))).map((r) => ({
id: r.id,
name: r.name,
provider: null,
registry: r,
})),
].filter((a) => matches(a, query));
const installed = all.filter(runnable);
const available = all.filter((a) => !runnable(a));
return [
...(installed.length ? [{ section: "Installed" }, ...installed] : []),
...(available.length ? [{ section: "Available" }, ...available] : []),
];
}
/** What the agent needs, if anything, as a trailing tag in the list. */
export function agentStatus(agent: AgentProvider): { label: string; tone: "muted" | "accent" } | null {
if (agent.source.kind === "registry" && agent.source.update) return { label: "Update", tone: "accent" };
if (agent.commandOverridden || agent.source.kind === "custom") return { label: "Custom", tone: "accent" };
if (!agent.installed) return { label: "Not found", tone: "muted" };
return null;
}
function AgentItem({ agent, working }: { agent: Agent; working: boolean }) {
const status = agent.provider
? agentStatus(agent.provider)
: agent.registry?.installed != null && agent.registry.update
? ({ label: "Update", tone: "accent" } as const)
: null;
return (
<div className={clsx(styles.agentRow, !runnable(agent) && styles.agentMissing)}>
<Icon name={providerIcon(agent.id)} size={16} />
<span className={styles.agentName}>{agent.name}</span>{" "}
{working ? (
<Spinner size={12} />
) : (
status && (
<span className={clsx(styles.tag, status.tone === "accent" && styles.tagAccent)}>
<T>{status.label}</T>
</span>
)
)}
</div>
);
}
export function AgentList({
backend,
registry: registryBackend,
onChanged,
}: {
backend: CommandsBackend;
registry: RegistryBackend;
onChanged?: () => void;
}) {
const [providers, setProviders] = useState<AgentProvider[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [registry, setRegistry] = useState<AgentRegistry | null>(null);
const [registryError, setRegistryError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [job, setJob] = useState<Job | null>(null);
const [removing, setRemoving] = useState<string | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
let live = true;
backend.providers().then(
(list) => live && setProviders(list),
(e: unknown) => live && setError(errorText(e)),
);
return () => {
live = false;
};
}, [backend]);
useEffect(() => {
const request = nextRequestId();
let live = true;
registryBackend
.list(request)
.then((r) => live && setRegistry(r))
.catch((e: unknown) => live && !isCancelled(e) && setRegistryError(errorText(e)));
return () => {
live = false;
registryBackend.cancel(request);
};
}, [registryBackend]);
const refresh = useCallback(async () => {
setProviders(await backend.providers());
}, [backend]);
const changed = useCallback(() => {
void refresh().catch((e: unknown) => setError(errorText(e)));
onChanged?.();
}, [refresh, onChanged]);
// Installs started before the page opened: follow them until they end.
const background = registry?.agents.some((a) => a.installing) ?? false;
useEffect(() => {
if (!background) return;
let live = true;
let request: RequestId | null = null;
const timer = setInterval(() => {
if (request != null) return;
const r = (request = nextRequestId());
registryBackend
.list(r)
.then((next) => {
if (!live) return;
setRegistry(next);
if (!next.agents.some((a) => a.installing)) changed();
})
.catch(() => {})
.finally(() => {
if (request === r) request = null;
});
}, BACKGROUND_POLL_MS);
return () => {
live = false;
clearInterval(timer);
if (request != null) registryBackend.cancel(request);
};
}, [registryBackend, background, changed]);
const rows = useMemo(
() => (providers ? agentRows(providers, registry?.agents ?? [], query) : []),
[providers, registry, query],
);
if (error)
return (
<p className={styles.error} role="alert">
{error}
</p>
);
if (!providers) return <Spinner size={14} />;
const setAgentError = (id: string, message: string | null) =>
setErrors((all) => {
const { [id]: _, ...rest } = all;
return message ? { ...rest, [id]: message } : rest;
});
const install = async (agent: Agent) => {
const entry = agent.registry;
if (!entry) return;
if (
entry.noChecksum &&
!(await confirm({
title: tx`Install ${agent.name}`,
message: tx`${agent.name} provides no checksum for its download, so the downloaded file cannot be verified. Install it anyway?`,
ok: tx("Install"),
icon: "warning",
}))
)
return;
const request = nextRequestId();
setAgentError(agent.id, null);
setJob({ id: agent.id, request, fraction: null, message: tx`Installing ${agent.name} ${entry.version}…` });
registryBackend
.install(agent.id, request, (e) =>
setJob((j) =>
j && j.request === request
? e.type === "progress"
? { ...j, fraction: e.fraction, message: e.message }
: { ...j, message: e.text }
: j,
),
)
.then(() => {
setRegistry((r) => r && { ...r, agents: afterInstall(r.agents, agent.id) });
changed();
})
.catch((e: unknown) => !isCancelled(e) && setAgentError(agent.id, errorText(e)))
.finally(() => setJob(null));
};
const remove = async (agent: Agent) => {
if (
!(await confirm({
title: tx`Remove ${agent.name}`,
message: tx`Remove ${agent.name} from this computer? Its tasks stay, but can't continue until it is installed again.`,
ok: tx("Remove"),
}))
)
return;
setAgentError(agent.id, null);
setRemoving(agent.id);
try {
await registryBackend.remove(agent.id);
setRegistry((r) => r && { ...r, agents: afterRemove(r.agents, agent.id) });
changed();
} catch (e) {
setAgentError(agent.id, errorText(e));
} finally {
setRemoving(null);
}
};
const found = rows.findIndex((r) => !isSection(r) && r.id === selected);
const index = found >= 0 ? found : rows.findIndex((r) => !isSection(r));
const agent = index >= 0 ? (rows[index] as Agent) : null;
const jobOf = (a: Agent): Job | null =>
job?.id === a.id
? job
: a.registry?.installing
? { id: a.id, request: null, fraction: null, message: tx`Installing ${a.name} in the background…` }
: null;
const busy = job != null || removing != null || background;
return (
<div className={styles.split}>
<div className={styles.master}>
<SearchField
className={styles.search}
value={query}
onChange={setQuery}
placeholder={tx("Search agents")}
aria-label="Search agents"
clearable
/>
{rows.length ? (
<List
className={styles.list}
items={rows}
getKey={(r) => (isSection(r) ? `section:${r.section}` : r.id)}
isSection={isSection}
renderSection={(r) => (
<div className={styles.listSection}>
<T>{(r as { section: string }).section}</T>
</div>
)}
activeIndex={index}
onActiveChange={(i) => {
const r = rows[i];
if (r && !isSection(r)) setSelected(r.id);
}}
rowHeight={28}
sectionHeight={26}
aria-label={tx("Agents")}
renderItem={(r) => <AgentItem agent={r as Agent} working={jobOf(r as Agent) != null} />}
/>
) : (
query && (
<p className={styles.empty}>
<T>No agents match</T>
</p>
)
)}
{(!registry || registry.warning) && (
<div className={styles.registryState}>
{registryError ? (
<span className={styles.error} role="alert">
{tx`Cannot load the ACP Registry: ${registryError}`}
</span>
) : registry?.warning ? (
<span className={styles.warning} role="status">
{registry.warning}
</span>
) : (
<>
<Spinner size={12} />
<T>Loading the ACP Registry…</T>
</>
)}
</div>
)}
</div>
{agent && (
<Details
key={agent.id}
agent={agent}
backend={backend}
refresh={refresh}
onChanged={onChanged}
job={jobOf(agent)}
busy={busy}
removing={removing === agent.id}
error={errors[agent.id] ?? null}
onInstall={() => void install(agent)}
onRemove={() => void remove(agent)}
onCancel={(request) => registryBackend.cancel(request)}
/>
)}
</div>
);
}
/** A titled separator over its indented rows: "Launch ─────────". */
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className={styles.section} aria-label={tx(title)}>
<div className={styles.sectionTitle}>
<T>{title}</T>
<span aria-hidden className={styles.sectionRule} />
</div>
<div className={styles.sectionBody}>{children}</div>
</section>
);
}
/** "Label: control", with an optional hint under the control. */
function Row({
label,
htmlFor,
hint,
children,
}: {
label: ReactNode;
htmlFor?: string;
hint?: ReactNode;
children: ReactNode;
}) {
return (
<>
{htmlFor ? (
<label className={styles.label} htmlFor={htmlFor}>
{label}
</label>
) : (
<span className={styles.label}>{label}</span>
)}
<div className={styles.control}>{children}</div>
{hint && <p className={styles.rowHint}>{hint}</p>}
</>
);
}
function Details({
agent,
backend,
refresh,
onChanged,
job,
busy,
removing,
error,
onInstall,
onRemove,
onCancel,
}: {
agent: Agent;
backend: CommandsBackend;
refresh: () => Promise<void>;
onChanged?: () => void;
job: Job | null;
busy: boolean;
removing: boolean;
error: string | null;
onInstall: () => void;
onRemove: () => void;
onCancel: (request: RequestId) => void;
}) {
const { provider, registry } = agent;
const installed = provider?.source.kind === "registry" || (!provider && registry?.installed != null);
const canInstall = registry?.distribution != null;
const update =
provider?.source.kind === "registry" ? provider.source.update : registry?.update ? registry.version : null;
const facts = provider
? [providerStatus(provider)]
: [
registry?.version,
registry?.distribution ? tx(DISTRIBUTION_LABEL[registry.distribution]) : null,
registry?.authors.join(", ") || null,
registry?.license,
];
return (
<section className={styles.details} aria-label={agent.name}>
<header className={styles.header}>
<Icon name={providerIcon(agent.id)} size={24} />
<div className={styles.heading}>
<h3>{agent.name}</h3>
<div className={styles.facts}>
{provider && (
<span
aria-hidden
className={clsx(
styles.dot,
provider.commandOverridden || provider.source.kind === "custom"
? styles.dotCustom
: provider.installed && styles.dotFound,
)}
/>
)}
<span>{facts.filter(Boolean).join(" · ")}</span>
</div>
</div>
<div className={styles.actions}>
{job ? (
job.request != null && (
<Button size="small" onClick={() => onCancel(job.request!)}>
Cancel
</Button>
)
) : (
<>
{!runnable(agent) && canInstall && (
<Button size="small" variant="primary" disabled={busy} onClick={onInstall}>
Install
</Button>
)}
{installed && update && canInstall && (
<Button size="small" variant="primary" disabled={busy} onClick={onInstall}>
Update
</Button>
)}
{installed && (
<Button size="small" disabled={busy} onClick={onRemove}>
{removing ? "Removing…" : "Remove"}
</Button>
)}
</>
)}
</div>
</header>
{job && (
<div className={styles.progress}>
<ProgressBar value={job.fraction} aria-label={tx`Installing ${agent.name}`} />
<span className={styles.hint}>{job.message}</span>
</div>
)}
{!job && installed && update && <p className={styles.notice}>{tx`${update} is available`}</p>}
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
{registry?.description && <p className={styles.description}>{registry.description}</p>}
{!provider && registry?.noChecksum && (
<p className={styles.warning}>
<T>No checksum provided: the download can't be verified.</T>
</p>
)}
{registry && (registry.repository || registry.website) && (
<div className={styles.links}>
{registry.repository && <ExternalLink url={registry.repository} label="Repository" />}
{registry.website && registry.website !== registry.repository && (
<ExternalLink url={registry.website} label="Website" />
)}
</div>
)}
{provider && <ProviderSettings agent={provider} backend={backend} refresh={refresh} onChanged={onChanged} />}
{registry && (
<p className={styles.thirdParty}>
<T>
Agents in the ACP Registry are third-party software. They run on this computer with your permissions and can
read and change your files. Install only agents you trust.
</T>
</p>
)}
</section>
);
}
function providerStatus(agent: AgentProvider): string {
if (agent.source.kind === "registry") return tx`ACP Registry · ${agent.source.version}`;
if (agent.source.kind === "custom") return tx("Agent file");
if (agent.commandOverridden) return tx("Custom executable");
return agent.installed ? tx("Found on PATH") : tx("Not found on PATH");
}
function ProviderSettings({
agent,
backend,
refresh,
onChanged,
}: {
agent: AgentProvider;
backend: CommandsBackend;
refresh: () => Promise<void>;
onChanged?: () => void;
}) {
const form = useSettingsForm();
const base = agent.commandOverridden ? agent.program : "";
const { value, current, set, saving } = form.useExternal(`agents.${agent.id}.command`, base, async (draft) => {
const command = draft.trim() || null;
await backend.set(agent.id, command);
await refresh();
onChanged?.();
return command === agent.defaultProgram ? "" : (command ?? "");
});
const envBase = formatEnv(agent.env);
const env = form.useExternal(`agents.${agent.id}.env`, envBase, async (draft) => {
const parsed = parseEnv(draft);
await backend.setEnv(agent.id, parsed);
await refresh();
onChanged?.();
return formatEnv(parsed);
});
const [error, setError] = useState<string | null>(null);
// An edit can have been staged by a previous page instance. Its Apply callback
// refreshes that instance; the mounted one follows the newly applied value too.
useEffect(() => {
if (!saving && current !== base) void refresh().catch((e: unknown) => setError(errorText(e)));
}, [base, current, saving, refresh]);
const launch = [agent.defaultProgram, ...agent.defaultArgs].join(" ");
const envEditable = agent.commandReadOnlyReason !== "registry" && agent.commandReadOnlyReason !== "customAgent";
return (
<>
{agent.loginHint && (
<Section title="Sign In">
<p className={styles.text}>
<LoginHint text={agent.loginHint} />
</p>
</Section>
)}
<Section title="New Tasks">
<AgentDefaults agent={agent} />
</Section>
<Section title="Launch">
<div className={styles.form}>
<Row label={<T>Command</T>}>
<code className={styles.launch} title={launch}>
{launch}
</code>
</Row>
{agent.commandEditable ? (
<Row
label={<T>Executable</T>}
htmlFor={`agent-executable-${agent.id}`}
hint={<T>Leave empty to find the executable on PATH. Changes apply to new sessions.</T>}
>
<div className={styles.field}>
<Input
id={`agent-executable-${agent.id}`}
mono
value={value}
disabled={saving}
placeholder={tx`Auto-detect (${agent.defaultProgram})`}
onChange={(e) => set(e.target.value)}
/>
<IconButton
icon="folder-open"
title="Browse…"
disabled={saving}
onClick={async () => {
try {
const path = await backend.chooseExecutable(tx`Select ${agent.name}`, value);
if (path) set(path);
setError(null);
} catch (e) {
setError(errorText(e));
}
}}
/>
{value && <IconButton icon="undo" title="Reset" disabled={saving} onClick={() => set("")} />}
</div>
</Row>
) : (
<p className={styles.rowHint}>
{agent.commandReadOnlyReason === "packageRunner" ? (
<T>This agent runs through a package runner (npm or uv) and has no executable to choose.</T>
) : agent.commandReadOnlyReason === "registry" ? (
<T>This agent is installed from the ACP Registry, which launches it.</T>
) : (
<T>This agent is configured in its agent file.</T>
)}
</p>
)}
{envEditable && (
<Row
label={<T>Environment variables</T>}
htmlFor={`agent-env-${agent.id}`}
hint={
<T>Passed to the agent process, replacing variables of the same name. Changes apply to new sessions.</T>
}
>
<div className={styles.field}>
<Input
id={`agent-env-${agent.id}`}
mono
value={env.value}
disabled={env.saving}
placeholder="NAME=value; OTHER=value"
onChange={(e) => env.set(e.target.value)}
/>
</div>
</Row>
)}
</div>
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
</Section>
</>
);
}
/** `NAME=value` pairs separated by `;` or new lines. */
function parseEnv(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const raw of text.split(/[;\n]/)) {
const pair = raw.trim();
if (!pair) continue;
const eq = pair.indexOf("=");
const key = eq < 0 ? "" : pair.slice(0, eq).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(tx`Expected NAME=value: ${pair}`);
env[key] = pair.slice(eq + 1).trim();
}
return env;
}
const formatEnv = (env: Record<string, string>) =>
Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
/** Login hints are plain text with optional backtick-delimited command spans. */
function LoginHint({ text }: { text: string }) {
const translate = useTxTemplate();
return translate(text)
.split(/(`[^`]+`)/g)
.map((part, i) => (i % 2 === 1 ? <code key={i}>{part.slice(1, -1)}</code> : part));
}
function ExternalLink({ url, label }: { url: string; label: string }) {
// Links come from the registry: web pages only.
if (!/^https?:\/\//i.test(url)) return null;
return (
<button type="button" className={styles.link} title={url} onClick={() => void openExternalUrl(url)}>
<T>{label}</T>
</button>
);
}
/** The options a new task of `agent` starts with: each the agent's own default unless chosen. */
export function defaultOptions(agent: AgentProvider, chosen: Record<string, string>): AgentConfigOption[] {
const base = agent.config.filter((o) => o.category !== "thought_level" && o.values.length > 1);
const modelOption = base.find((o) => o.category === "model");
const model = (modelOption && chosen[modelOption.id]) || modelOption?.current;
const levels = (model && agent.thoughtLevels?.[model]) || [];
return [...base, ...levels.filter((o) => o.values.length > 1)];
}
function AgentDefaults({ agent }: { agent: AgentProvider }) {
const form = useSettingsForm();
const all = (form.useValue("agents.defaults") ?? {}) as Record<string, Record<string, string>>;
const chosen = all[agent.id] ?? {};
const options = defaultOptions(agent, chosen);
if (!options.length)
return (
<p className={styles.text}>
<T>The agent's models and modes can be chosen here after its first session.</T>
</p>
);
const choose = (id: string, value: string) => {
const { [id]: _, ...rest } = chosen;
const next = value ? { ...rest, [id]: value } : rest;
const { [agent.id]: __, ...others } = all;
form.set("agents.defaults", Object.keys(next).length ? { ...others, [agent.id]: next } : others);
};
return (
<div className={styles.form}>
{options.map((o) => {
const fallback = o.values.find((v) => v.value === o.current)?.name;
return (
<Row key={o.id} label={o.name} htmlFor={`agent-default-${agent.id}-${o.id}`}>
<div className={styles.select}>
<Select
id={`agent-default-${agent.id}-${o.id}`}
value={chosen[o.id] ?? ""}
options={[
{ value: "", label: fallback ? tx`Agent's default (${fallback})` : tx("Agent's default") },
...o.values.map((v) => ({
value: v.value,
label: v.name,
verbatim: true,
detail: v.description ?? undefined,
})),
]}
onChange={(value) => choose(o.id, value)}
/>
</div>
</Row>
);
})}
<p className={styles.rowHint}>
<T>What you pick on the New Task page wins; these apply until then and after switching agents.</T>
</p>
</div>
);
}
@@ -5,3 +5,134 @@
height: 100%;
min-height: 0;
}
/* The compact variant may shrink in toolbars; here it keeps its height above the panes. */
.tabs {
flex: none;
}
.notice,
.warning,
.error,
.empty {
margin: 0;
font-size: var(--fs-small);
overflow-wrap: anywhere;
}
.notice {
color: var(--fg-muted);
}
.warning {
color: var(--warning);
}
.error {
color: var(--danger);
white-space: pre-wrap;
}
.empty {
padding: 16px 4px;
color: var(--fg-subtle);
text-align: center;
}
.list {
flex: 1;
min-height: 240px;
overflow: auto;
border: 1px solid var(--border-strong);
border-radius: var(--radius-control);
}
.loading {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--fg-muted);
font-size: var(--fs-small);
}
.rows {
margin: 0;
padding: 0;
list-style: none;
}
.row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.row:last-child {
border-bottom: none;
}
.main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.titleLine {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.name {
color: var(--fg-strong);
font-weight: 600;
}
.version {
color: var(--fg-muted);
font-size: var(--fs-small);
}
.description {
margin: 0;
color: var(--fg-secondary);
font-size: var(--fs-small);
overflow-wrap: anywhere;
}
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 10px;
color: var(--fg-subtle);
font-size: var(--fs-tiny);
}
.link {
padding: 0;
border: none;
background: none;
color: var(--link);
font: inherit;
cursor: pointer;
}
.link:hover {
color: var(--link-hover);
text-decoration: underline;
}
.link:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 1px;
}
.progress {
display: flex;
flex-direction: column;
gap: 3px;
margin-top: 4px;
}
.progressText {
color: var(--fg-muted);
font-size: var(--fs-tiny);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actions {
flex: none;
display: flex;
gap: 6px;
}
+325 -4
View File
@@ -1,8 +1,54 @@
/** Settings › Agents › Coding Agents. */
import { AgentList } from "./AgentList";
import { tauriCommands, tauriRegistry, type CommandsBackend, type RegistryBackend } from "./registry";
/**
* Settings › Tools › Agents: the launch command of built-in agents (Claude Code…), and
* agents of the ACP Registry this machine can run, with Install / Update / Remove.
* Installs run in the background with progress, and keep running after the page closes
* (as a background task of the project windows); the page then shows them as installing.
*/
import { useEffect, useMemo, useState } from "react";
import type { AgentRegistry, RegistryAgent, RequestId } from "@/bindings";
import { isCancelled, nextRequestId } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { openExternalUrl } from "@/platform/openUrl";
import { Badge, Button, Icon, ProgressBar, SearchField, Spinner, T, Tabs } from "@/ui";
import { confirm } from "@/workbench/messages";
import { providerIcon } from "./model";
import {
DISTRIBUTION_LABEL,
afterInstall,
afterRemove,
matchesQuery,
rowActions,
sortAgents,
tauriCommands,
tauriRegistry,
type CommandsBackend,
type RegistryBackend,
} from "./registry";
import { BuiltinAgents } from "./BuiltinAgents";
import styles from "./AgentRegistry.module.css";
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
/** How often the page checks on installs it did not start. */
const BACKGROUND_POLL_MS = 1500;
interface Job {
id: string;
fraction: number | null;
message: string;
}
interface PageJob extends Job {
request: RequestId;
}
/** An install started before the page opened; its progress shows in the background tasks. */
const backgroundJob = (agent: RegistryAgent): Job => ({
id: agent.id,
fraction: null,
message: tx`Installing ${agent.name} in the background…`,
});
export function AgentRegistryPage({
onChanged,
backend = tauriRegistry,
@@ -13,9 +59,284 @@ export function AgentRegistryPage({
backend?: RegistryBackend;
commandBackend?: CommandsBackend;
}) {
const [tab, setTab] = useState("builtin");
return (
<div className={styles.body}>
<AgentList backend={commandBackend} registry={backend} onChanged={onChanged} />
<Tabs
variant="compact"
className={styles.tabs}
items={[
{ id: "builtin", label: "Built-in" },
{ id: "registry", label: "ACP Registry" },
]}
activeId={tab}
onSelect={setTab}
aria-label={tx("Agents")}
/>
{tab === "builtin" ? (
<BuiltinAgents backend={commandBackend} onChanged={onChanged} />
) : (
<RegistryList backend={backend} onChanged={onChanged} />
)}
</div>
);
}
function RegistryList({ backend, onChanged }: { backend: RegistryBackend; onChanged?: () => void }) {
const [registry, setRegistry] = useState<AgentRegistry | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [job, setJob] = useState<PageJob | null>(null);
const [removing, setRemoving] = useState<string | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
const request = nextRequestId();
let live = true;
backend
.list(request)
.then((r) => live && setRegistry(r))
.catch((e: unknown) => live && !isCancelled(e) && setLoadError(errorText(e)));
return () => {
live = false;
backend.cancel(request);
};
}, [backend]);
// Installs started before the page opened: follow them until they end.
const background = registry?.agents.some((a) => a.installing) ?? false;
useEffect(() => {
if (!background) return;
let live = true;
let request: RequestId | null = null;
const timer = setInterval(() => {
if (request != null) return;
const r = (request = nextRequestId());
backend
.list(r)
.then((next) => {
if (!live) return;
setRegistry(next);
if (!next.agents.some((a) => a.installing)) onChanged?.();
})
.catch(() => {})
.finally(() => {
if (request === r) request = null;
});
}, BACKGROUND_POLL_MS);
return () => {
live = false;
clearInterval(timer);
if (request != null) backend.cancel(request);
};
}, [backend, background, onChanged]);
const agents = useMemo(
() => sortAgents((registry?.agents ?? []).filter((a) => matchesQuery(a, query))),
[registry, query],
);
const busy = job != null || removing != null || background;
const setError = (id: string, error: string | null) =>
setErrors((all) => {
const { [id]: _, ...rest } = all;
return error ? { ...rest, [id]: error } : rest;
});
const install = async (agent: RegistryAgent) => {
if (
agent.noChecksum &&
!(await confirm({
title: tx`Install ${agent.name}`,
message: tx`${agent.name} provides no checksum for its download, so the downloaded file cannot be verified. Install it anyway?`,
ok: tx("Install"),
icon: "warning",
}))
)
return;
const request = nextRequestId();
setError(agent.id, null);
setJob({ id: agent.id, request, fraction: null, message: tx`Installing ${agent.name} ${agent.version}…` });
backend
.install(agent.id, request, (e) =>
setJob((j) =>
j && j.request === request
? e.type === "progress"
? { ...j, fraction: e.fraction, message: e.message }
: { ...j, message: e.text }
: j,
),
)
.then(() => {
setRegistry((r) => r && { ...r, agents: afterInstall(r.agents, agent.id) });
onChanged?.();
})
.catch((e: unknown) => !isCancelled(e) && setError(agent.id, errorText(e)))
.finally(() => setJob(null));
};
const remove = (agent: RegistryAgent) => {
setError(agent.id, null);
setRemoving(agent.id);
backend
.remove(agent.id)
.then(() => {
setRegistry((r) => r && { ...r, agents: afterRemove(r.agents, agent.id) });
onChanged?.();
})
.catch((e: unknown) => setError(agent.id, errorText(e)))
.finally(() => setRemoving(null));
};
return (
<div className={styles.body}>
<SearchField
autoFocus
value={query}
onChange={setQuery}
placeholder={tx("Search the ACP Registry")}
aria-label="Search agents"
clearable
/>
<p className={styles.notice}>
<T>
Agents in the ACP Registry are third-party software. They run on this computer with your permissions and can
read and change your files. Install only agents you trust.
</T>
</p>
{registry?.warning && (
<p className={styles.warning} role="status">
{registry.warning}
</p>
)}
<div className={styles.list} aria-busy={!registry && !loadError}>
{loadError ? (
<p className={styles.error} role="alert">
{tx`Cannot load the ACP Registry: ${loadError}`}
</p>
) : !registry ? (
<div className={styles.loading}>
<Spinner size={14} />
<T>Loading the ACP Registry…</T>
</div>
) : agents.length === 0 ? (
<p className={styles.empty}>
{query ? <T>No agents match</T> : <T>The registry has no agents for this system</T>}
</p>
) : (
<ul className={styles.rows} aria-label="Registry agents">
{agents.map((agent) => (
<AgentRow
key={agent.id}
agent={agent}
job={job?.id === agent.id ? job : agent.installing ? backgroundJob(agent) : null}
removing={removing === agent.id}
disabled={busy}
error={errors[agent.id] ?? null}
onInstall={() => void install(agent)}
onRemove={() => remove(agent)}
onCancel={job?.id === agent.id ? () => backend.cancel(job.request) : undefined}
/>
))}
</ul>
)}
</div>
</div>
);
}
function AgentRow({
agent,
job,
removing,
disabled,
error,
onInstall,
onRemove,
onCancel,
}: {
agent: RegistryAgent;
job: Job | null;
removing: boolean;
disabled: boolean;
error: string | null;
onInstall: () => void;
onRemove: () => void;
/** Absent for an install this page did not start (stop it from the background tasks). */
onCancel?: () => void;
}) {
const actions = rowActions(agent);
const titleId = `agent-${agent.id}`;
return (
<li className={styles.row} aria-labelledby={titleId}>
<div className={styles.main}>
<div className={styles.titleLine}>
<Icon name={providerIcon(agent.id)} size={16} />
<span id={titleId} className={styles.name}>
{agent.name}
</span>
<span className={styles.version}>{agent.installed ?? agent.version}</span>
{agent.distribution && <Badge>{DISTRIBUTION_LABEL[agent.distribution]}</Badge>}
{agent.noChecksum && <Badge tone="warning">{tx("No checksum provided")}</Badge>}
{actions.update && <Badge tone="accent">{tx`${agent.version} available`}</Badge>}
{agent.installed != null && !actions.update && <Badge tone="success">{tx("Installed")}</Badge>}
</div>
{agent.description && <p className={styles.description}>{agent.description}</p>}
<div className={styles.meta}>
{agent.authors.length > 0 && <span>{agent.authors.join(", ")}</span>}
{agent.license && <span>{agent.license}</span>}
{agent.repository && <ExternalLink url={agent.repository} label="Repository" />}
{agent.website && agent.website !== agent.repository && <ExternalLink url={agent.website} label="Website" />}
</div>
{job && (
<div className={styles.progress}>
<ProgressBar value={job.fraction} aria-label={tx`Installing ${agent.name}`} />
<span className={styles.progressText}>{job.message}</span>
</div>
)}
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
</div>
<div className={styles.actions}>
{job ? (
onCancel && (
<Button size="small" onClick={onCancel}>
Cancel
</Button>
)
) : (
<>
{actions.install && (
<Button size="small" variant="primary" disabled={disabled} onClick={onInstall}>
Install
</Button>
)}
{actions.update && (
<Button size="small" variant="primary" disabled={disabled} onClick={onInstall}>
Update
</Button>
)}
{actions.remove && (
<Button size="small" disabled={disabled} onClick={onRemove}>
{removing ? "Removing…" : "Remove"}
</Button>
)}
</>
)}
</div>
</li>
);
}
function ExternalLink({ url, label }: { url: string; label: string }) {
// Links come from the registry: web pages only.
if (!/^https?:\/\//i.test(url)) return null;
return (
<button type="button" className={styles.link} title={url} onClick={() => void openExternalUrl(url)}>
<T>{label}</T>
</button>
);
}
+1 -1
View File
@@ -93,7 +93,7 @@ export function ReplyActions({
{task && reply && operations.fork && (
<IconButton
icon="branch"
title="Fork"
title="Branch Conversation from Here"
size="small"
action="agents.forkReply"
actionArg={{ task: task.id, turn: reply.turn }}
+9 -1
View File
@@ -79,7 +79,6 @@
}
.age {
flex: none;
margin-left: auto;
padding-right: 8px;
color: var(--fg-subtle);
font-size: var(--fs-small);
@@ -187,3 +186,12 @@
border-bottom: 1px solid var(--border);
font-weight: 600;
}
.location {
display: flex;
align-items: center;
gap: 2px;
padding: 1px;
border: 1px solid var(--border);
border-radius: var(--radius-small);
}
+16 -14
View File
@@ -1,15 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, renderHook, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { AgentTask } from "@/bindings";
import * as actions from "@/platform/actions";
import { registerActions } from "@/platform/actions";
import { EDITOR_TAB_MIME, type EditorInput } from "@/workbench/contributions";
import { confirmChecked, showError } from "@/workbench/messages";
import { ContextMenuHost, dragSession, hideContextMenu } from "@/ui";
import { AgentsHeaderActions, AgentsView } from "./AgentsView";
import { AgentsHeaderActions, AgentsLocationActions, AgentsView } from "./AgentsView";
import { SESSION_KIND } from "./model";
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, useAgents } from "./store";
import { taskActions, taskMenu, useAgentsHeaderMenu } from "./taskActions";
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, setSessionLocation, useAgents } from "./store";
import { taskActions, taskMenu } from "./taskActions";
import styles from "./Agents.module.css";
const editor = vi.hoisted(() => ({
@@ -117,6 +117,7 @@ afterEach(() => {
function setup() {
render(
<>
<AgentsLocationActions />
<AgentsHeaderActions />
<AgentsView />
<ContextMenuHost />
@@ -165,6 +166,7 @@ describe("Agents docked view", () => {
useAgents.setState({ docked: "one" });
setup();
expect(screen.getByTestId("docked-session")).toBeTruthy();
expect(screen.getByRole("button", { name: "Chat in Editor" })).toBeTruthy();
expect(openTask).not.toHaveBeenCalled();
expect(screen.queryByRole("tree", { name: "Agent tasks" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
@@ -207,13 +209,15 @@ describe("Agents docked view", () => {
expect(screen.getByTestId("docked-session")).toBeTruthy();
});
it("offers placement in the Agents options menu, checked by the current one", () => {
useAgents.setState({ sessionLocation: "toolWindow" });
const { result } = renderHook(() => useAgentsHeaderMenu());
expect(result.current).toEqual([
{ kind: "action", action: "agents.useToolWindow", icon: "", checked: true },
{ kind: "action", action: "agents.useEditor", icon: "", checked: false },
]);
it("switches placement from the global Agents header", () => {
useAgents.setState({ docked: "one", sessionLocation: "toolWindow" });
setup();
expect(screen.queryByRole("button", { name: "Open in Editor" })).toBeNull();
expect(screen.getByRole("button", { name: "Chat in Tool Window" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "Chat in Editor" }));
expect(setSessionLocation).toHaveBeenCalledWith("editor");
fireEvent.click(screen.getByRole("button", { name: "Chat in Tool Window" }));
expect(setSessionLocation).toHaveBeenCalledWith("toolWindow");
});
it("opens tabs as usual without a docked conversation", () => {
@@ -311,9 +315,7 @@ describe("Agents task menu", () => {
const menu = taskMenu([useAgents.getState().tasks.get("one")!]);
expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.dockTask" }));
expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.undockTask" }));
// A click opens the task: the menu doesn't repeat it.
expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.openTask" }));
expect(menu[0]).toMatchObject({ action: "agents.renameTask" });
expect(menu).toContainEqual(expect.objectContaining({ action: "agents.openTask" }));
});
});
+18 -15
View File
@@ -15,16 +15,7 @@ import {
showContextMenu,
startPointerDrag,
} from "@/ui";
import {
SESSION_KIND,
TASK_DRAG,
age,
branchLabel,
groupTasks,
providerIcon,
sessionKey,
type TaskGroup,
} from "./model";
import { SESSION_KIND, TASK_DRAG, age, branchLabel, groupTasks, sessionKey, type TaskGroup } from "./model";
import { openHome, useAgents } from "./store";
import { taskMenu } from "./taskActions";
import { SessionEditor } from "./SessionEditor";
@@ -38,6 +29,17 @@ export function AgentsHeaderActions() {
return <IconButton icon="plus" action="agents.newTask" />;
}
export function AgentsLocationActions() {
const location = useAgents((s) => s.sessionLocation);
const tx = useTx();
return (
<div role="group" aria-label={tx("Conversation location")} className={styles.location}>
<IconButton icon="open-in-tool-window" action="agents.useToolWindow" pressed={location === "toolWindow"} />
<IconButton icon="editor-only" action="agents.useEditor" pressed={location === "editor"} />
</div>
);
}
type Node = { kind: "group"; group: TaskGroup } | { kind: "task"; task: AgentTask };
const nodeKey = (n: Node) => (n.kind === "group" ? `g:${n.group.status}` : n.task.id);
@@ -57,7 +59,7 @@ const sessionPayloadTask = (payload: string): string | null => {
}
};
function TaskStatusIcon({ status }: { status: Status }) {
export function TaskStatusIcon({ status }: { status: Status }) {
switch (status) {
case "needsInput":
return <Icon name="warning" size={13} label="Needs input" />;
@@ -193,12 +195,10 @@ export function AgentsView() {
if (inline && !e.metaKey && !e.ctrlKey && !e.shiftKey) showTask(n.task.id);
}}
>
<Icon name={providerIcon(n.task.provider)} size={16} label={providerName(n.task.provider)} />
<TaskStatusIcon status={n.task.status} />
<span className={styles.title} data-unread={unread.has(n.task.id) || undefined}>
{n.task.title}
</span>
{/* Ready tasks say so by their group; the others show what they're doing. */}
{n.task.status !== "ready" && <TaskStatusIcon status={n.task.status} />}
{unread.has(n.task.id) && (
<span className={styles.unreadDot} role="img" aria-label={tx("Unread")} title={tx("Unread")} />
)}
@@ -207,7 +207,10 @@ export function AgentsView() {
{basename(n.task.projectRoot ?? project.root)}
</span>
)}
{n.task.isolation === "worktree" && <span className={styles.detail}>{branchLabel(n.task.branch)}</span>}
<span className={styles.detail}>
{providerName(n.task.provider)}
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
</span>
<span className={styles.age}>{age(n.task.updatedMs ?? 0)}</span>
</span>
)
@@ -0,0 +1,123 @@
.split {
display: flex;
min-width: 640px;
flex: none;
min-height: 320px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-control);
overflow: hidden;
}
.master {
flex: 0 0 300px;
padding: 6px;
border-right: 1px solid var(--border);
}
.agentRow {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
padding: 0 10px;
}
.agentMissing .agentName {
color: var(--fg-muted);
}
.agentMissing svg {
opacity: 0.6;
}
.tag {
flex: none;
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.tagCustom {
color: var(--accent);
}
.agentName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.details {
flex: 1;
min-width: 0;
padding: 20px;
overflow: auto;
}
.title {
display: flex;
align-items: center;
gap: 8px;
color: var(--fg-strong);
}
.title h3 {
margin: 0;
font-size: var(--fs-input-large);
}
.subtitle {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
margin: 6px 0 20px 28px;
color: var(--fg-muted);
font-size: var(--fs-small);
}
.dot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--fg-subtle);
}
.dotFound {
background: var(--success);
}
.dotCustom {
background: var(--accent);
}
.launch {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
}
.section {
margin: 20px 0 6px;
font-size: var(--fs-ui);
font-weight: 600;
color: var(--fg-strong);
}
.section + .hint {
margin-top: 0;
}
.hint code {
font-family: var(--font-mono);
color: var(--fg);
}
.label {
display: block;
margin-bottom: 6px;
}
.controls {
display: flex;
align-items: center;
gap: 6px;
}
.controls > :first-child {
flex: 1;
min-width: 0;
}
.hint {
margin: 8px 0 16px;
color: var(--fg-muted);
font-size: var(--fs-small);
overflow-wrap: anywhere;
}
.error {
color: var(--danger);
white-space: pre-wrap;
}
+244
View File
@@ -0,0 +1,244 @@
/** Built-in launch settings; each executable is staged independently until Apply. */
import { useCallback, useEffect, useState } from "react";
import type { AgentProvider } from "@/bindings";
import { tx, useTxTemplate } from "@/platform/i18n";
import clsx from "clsx";
import { Icon, IconButton, Input, List, Spinner, T } from "@/ui";
import { useSettingsForm } from "@/workbench/contributions";
import { providerIcon } from "./model";
import type { CommandsBackend } from "./registry";
import styles from "./BuiltinAgents.module.css";
/** Trailing list tag: a missing agent is a normal state, so it is muted rather than a warning. */
function Status({ agent }: { agent: AgentProvider }) {
if (agent.commandOverridden)
return (
<span className={clsx(styles.tag, styles.tagCustom)}>
<T>Custom</T>
</span>
);
if (agent.installed) return null;
return (
<span className={styles.tag}>
<T>Not found</T>
</span>
);
}
export function BuiltinAgents({ backend, onChanged }: { backend: CommandsBackend; onChanged?: () => void }) {
const [agents, setAgents] = useState<AgentProvider[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState(0);
useEffect(() => {
let live = true;
backend.providers().then(
(list) => live && setAgents(list.filter((p) => p.defaultProgram != null)),
(e: unknown) => live && setError(e instanceof Error ? e.message : String(e)),
);
return () => {
live = false;
};
}, [backend]);
const refresh = useCallback(async () => {
const list = await backend.providers();
setAgents(list.filter((p) => p.defaultProgram != null));
}, [backend]);
if (error)
return (
<p className={styles.error} role="alert">
{error}
</p>
);
if (!agents) return <Spinner size={14} />;
const agent = agents[selected] ?? agents[0];
return (
<div className={styles.split}>
<List
className={styles.master}
items={agents}
getKey={(p) => p.id}
activeIndex={selected}
onActiveChange={setSelected}
rowHeight={32}
aria-label={tx("Built-in agents")}
renderItem={(p) => (
<div className={clsx(styles.agentRow, !p.installed && !p.commandOverridden && styles.agentMissing)}>
<Icon name={providerIcon(p.id)} size={16} />
<span className={styles.agentName}>{p.name}</span> <Status agent={p} />
</div>
)}
/>
{agent && <Details key={agent.id} agent={agent} backend={backend} refresh={refresh} onChanged={onChanged} />}
</div>
);
}
function Details({
agent,
backend,
refresh,
onChanged,
}: {
agent: AgentProvider;
backend: CommandsBackend;
refresh: () => Promise<void>;
onChanged?: () => void;
}) {
const form = useSettingsForm();
const base = agent.commandOverridden ? agent.program : "";
const { value, current, set, saving } = form.useExternal(`agents.${agent.id}.command`, base, async (draft) => {
const command = draft.trim() || null;
await backend.set(agent.id, command);
await refresh();
onChanged?.();
return command === agent.defaultProgram ? "" : (command ?? "");
});
const envBase = formatEnv(agent.env);
const env = form.useExternal(`agents.${agent.id}.env`, envBase, async (draft) => {
const parsed = parseEnv(draft);
await backend.setEnv(agent.id, parsed);
await refresh();
onChanged?.();
return formatEnv(parsed);
});
const [error, setError] = useState<string | null>(null);
// An edit can have been staged by a previous page instance. Its Apply callback
// refreshes that instance; the mounted one follows the newly applied value too.
useEffect(() => {
if (!saving && current !== base) {
void refresh().catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)));
}
}, [base, current, saving, refresh]);
const launch = [agent.defaultProgram, ...agent.defaultArgs].join(" ");
const status = agent.commandOverridden
? tx("Custom executable")
: agent.installed
? tx("Found on PATH")
: tx("Not found on PATH");
return (
<section className={styles.details} aria-label={tx`${agent.name} executable`}>
<div className={styles.title}>
<Icon name={providerIcon(agent.id)} size={20} />
<h3>{agent.name}</h3>
</div>
<div className={styles.subtitle}>
<span
aria-hidden
className={clsx(styles.dot, agent.commandOverridden ? styles.dotCustom : agent.installed && styles.dotFound)}
/>
<span>{status}</span>
<span aria-hidden>·</span>
<code className={styles.launch} title={tx("Launch command")}>
{launch}
</code>
</div>
{agent.commandEditable ? (
<>
<label className={styles.label} htmlFor={`agent-executable-${agent.id}`}>
<T>Executable</T>
</label>
<div className={styles.controls}>
<Input
id={`agent-executable-${agent.id}`}
mono
value={value}
disabled={saving}
placeholder={tx`Auto-detect (${agent.defaultProgram})`}
onChange={(e) => set(e.target.value)}
/>
<IconButton
icon="folder-open"
title="Browse…"
disabled={saving}
onClick={async () => {
try {
const path = await backend.chooseExecutable(tx`Select ${agent.name}`, value);
if (path) set(path);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}}
/>
{value && <IconButton icon="undo" title="Reset" disabled={saving} onClick={() => set("")} />}
</div>
<p className={styles.hint}>
<T>Leave empty to find the executable on PATH. Changes apply to new sessions.</T>
</p>
</>
) : (
<p className={styles.hint}>
{agent.commandReadOnlyReason === "packageRunner" ? (
<T>This agent runs through a package runner (npm or uv) and has no executable to choose.</T>
) : agent.commandReadOnlyReason === "registry" ? (
<T>This agent is installed from the ACP Registry. Manage its installation in the ACP Registry tab.</T>
) : (
<T>This agent is configured in its agent file.</T>
)}
</p>
)}
{agent.commandReadOnlyReason !== "registry" && agent.commandReadOnlyReason !== "customAgent" && (
<>
<label className={styles.label} htmlFor={`agent-env-${agent.id}`}>
<T>Environment variables</T>
</label>
<div className={styles.controls}>
<Input
id={`agent-env-${agent.id}`}
mono
value={env.value}
disabled={env.saving}
placeholder="NAME=value; OTHER=value"
onChange={(e) => env.set(e.target.value)}
/>
</div>
<p className={styles.hint}>
<T>Passed to the agent process, replacing variables of the same name. Changes apply to new sessions.</T>
</p>
</>
)}
{agent.loginHint && (
<>
<h4 className={styles.section}>
<T>Sign in</T>
</h4>
<p className={styles.hint}>
<LoginHint text={agent.loginHint} />
</p>
</>
)}
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
</section>
);
}
/** `NAME=value` pairs separated by `;` or new lines. */
function parseEnv(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const raw of text.split(/[;\n]/)) {
const pair = raw.trim();
if (!pair) continue;
const eq = pair.indexOf("=");
const key = eq < 0 ? "" : pair.slice(0, eq).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(tx`Expected NAME=value: ${pair}`);
env[key] = pair.slice(eq + 1).trim();
}
return env;
}
const formatEnv = (env: Record<string, string>) =>
Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
/** Login hints are plain text with optional backtick-delimited command spans. */
function LoginHint({ text }: { text: string }) {
const translate = useTxTemplate();
return translate(text)
.split(/(`[^`]+`)/g)
.map((part, i) => (i % 2 === 1 ? <code key={i}>{part.slice(1, -1)}</code> : part));
}
+6 -5
View File
@@ -99,10 +99,15 @@
margin-left: auto;
}
/* Attach/reference buttons share the send button's 24px footprint and chrome. */
/* Plain toolbar icons, framed only on hover like the rest of the IDE's toolbars. */
.composerButton {
width: var(--h-control);
height: var(--h-control);
border-radius: var(--radius-small);
background: var(--bg-elev);
box-shadow: inset 0 0 0 1px var(--border);
}
.composerButton:hover:not(:disabled) {
background: var(--bg-hover);
}
.spacer {
flex: 1;
@@ -315,10 +320,6 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* The effort is in the model popup: the button keeps the model's name readable. */
.chipDetail {
display: none;
}
.inlineSetting {
display: none;
}
+5 -31
View File
@@ -15,7 +15,7 @@ import {
type ReactNode,
} from "react";
import clsx from "clsx";
import type { AgentCommand, AgentConfigOption, AgentImage, SendKey, WhileRunning } from "@/bindings";
import type { AgentCommand, AgentConfigOption, AgentImage } from "@/bindings";
import { call, commands, isTauri } from "@/platform/ipc";
import { nativeDropPoint } from "@/platform/nativeDrop";
import { invokeLocal } from "@/platform/tauriCore";
@@ -57,7 +57,6 @@ import {
import { composerDraft, updateComposer, useAgents } from "./store";
import { ImagePreview, imageSrc } from "./ImagePreview";
import { useCompletionAnchor } from "./useCompletionAnchor";
import { useAgentSettings } from "./agentSettings";
import styles from "./Composer.module.css";
import { isImeKey } from "@/platform/ime";
@@ -85,7 +84,7 @@ export interface ComposerProps {
/** Settings; Shift+Tab cycles the mode one. */
config: readonly AgentConfigOption[];
onConfig?: (option: string, value: string) => void;
/** `now`: send even while the agent works, into its turn (by default ⌘↵). */
/** `now`: ⌘↵ (send even while the agent works, interrupting it). */
onSubmit: (now: boolean) => void;
disabled?: boolean;
autoFocus?: boolean;
@@ -96,29 +95,6 @@ export interface ComposerProps {
className?: string;
}
/**
* What a key press in the composer sends: "queue" waits for the turn to end, "now" goes into
* it. The send key sends the way Settings › Agents › Chat says, with Shift (or the
* modifier, when Enter sends) the other way; without the send key, Enter is a new line.
*/
export function sendKeyAction(
e: { key: string; shiftKey: boolean; metaKey: boolean; ctrlKey: boolean; altKey?: boolean },
sendWith: SendKey,
whileRunning: WhileRunning,
): "queue" | "now" | null {
if (e.key !== "Enter" || e.altKey) return null;
const mod = e.metaKey || e.ctrlKey;
let other: boolean;
if (sendWith === "enter") {
if (e.shiftKey) return null;
other = mod;
} else {
if (!mod) return null;
other = e.shiftKey;
}
return (whileRunning === "steer") !== other ? "now" : "queue";
}
export function Composer(props: ComposerProps) {
const { id, root, projectRoot } = props;
const tx = useTx();
@@ -136,7 +112,6 @@ export function Composer(props: ComposerProps) {
const [active, setActive] = useState(0);
const composing = useRef(false);
const recalled = useRef<{ index: number; stash: string }>({ index: -1, stash: "" });
const { sendWith, whileRunning } = useAgentSettings();
useEffect(() => {
if (props.autoFocus) ref.current?.focus();
@@ -449,10 +424,9 @@ export function Composer(props: ComposerProps) {
return;
}
}
const send = sendKeyAction(e, sendWith, whileRunning);
if (send) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!props.disabled) props.onSubmit(send === "now");
if (!props.disabled) props.onSubmit(e.metaKey || e.ctrlKey);
recalled.current = { index: -1, stash: "" };
return;
}
@@ -626,7 +600,7 @@ export function Composer(props: ComposerProps) {
}}
/>
<IconButton
icon="image"
icon="file-image"
title="Attach Image…"
className={styles.composerButton}
onClick={() => imageInput.current?.click()}
+1 -3
View File
@@ -26,7 +26,6 @@ import {
useAgentCommands,
useAgents,
} from "./store";
import { useAgentSettings } from "./agentSettings";
import styles from "./Home.module.css";
export function HomeEditor({ docked = false }: { docked?: boolean; input?: EditorInput }) {
@@ -96,8 +95,7 @@ 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]) || {});
const config = draftConfig(provider, draft);
return (
<div className={clsx(styles.home, docked && styles.docked)}>
+5 -56
View File
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks";
import { resetMermaidForTests } from "../viewers/markdown/mermaid";
import { StrictMode } from "react";
import { Markdown, markdownBlocks, markdownHtml } from "./Markdown";
import { Markdown } from "./Markdown";
const { openFile, openExternalUrl } = vi.hoisted(() => ({
openFile: vi.fn().mockResolvedValue(undefined),
@@ -12,19 +12,12 @@ const { openFile, openExternalUrl } = vi.hoisted(() => ({
const copyText = vi.hoisted(() => vi.fn());
const readImage = vi.fn<(path: string) => Promise<string>>();
const PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
vi.mock("@/workbench/contributions", () => ({
editorService: () => ({ openFile }),
}));
vi.mock("@/workbench/contributions", () => ({ editorService: () => ({ openFile }) }));
vi.mock("@/platform/openUrl", () => ({ openExternalUrl }));
vi.mock("@/ui", async (importOriginal) => ({
...(await importOriginal<typeof import("@/ui")>()),
copyText,
}));
vi.mock("@/ui", async (importOriginal) => ({ ...(await importOriginal<typeof import("@/ui")>()), copyText }));
const mermaid = vi.hoisted(() => ({ initialize: vi.fn(), render: vi.fn() }));
vi.mock("mermaid", () => ({
default: { initialize: mermaid.initialize, render: mermaid.render },
}));
vi.mock("mermaid", () => ({ default: { initialize: mermaid.initialize, render: mermaid.render } }));
const DIAGRAM = "flowchart TD\n A-->B";
const FENCE = `\`\`\`mermaid\n${DIAGRAM}\n\`\`\`\n`;
@@ -177,11 +170,7 @@ it("reuses image reads while a reply streams, but reads again for another messag
});
it("reports read failure and retries on the next render", async () => {
readImage.mockRejectedValueOnce({
kind: "io",
message: "missing",
data: null,
});
readImage.mockRejectedValueOnce({ kind: "io", message: "missing", data: null });
const { rerender } = render(<Markdown text="![QR](/tmp/missing.png)" cwd="/repo" />);
await waitFor(() => expect(screen.getByRole("img")).toHaveAttribute("alt", "Unable to load image: /tmp/missing.png"));
expect(screen.getByRole("img")).not.toHaveAttribute("src");
@@ -285,43 +274,3 @@ describe("mermaid diagrams in a reply", () => {
expect(onDiagramDrawn).toHaveBeenCalledTimes(1);
});
});
describe("streamed replies", () => {
it("cuts a reply into blocks that join back into it", () => {
const text = "# Plan\n\nFirst *one*.\n\n- a\n- b\n\n```ts\nconst a = 1;\n```\n\nTail";
const blocks = markdownBlocks(text);
expect(blocks.join("")).toBe(text);
expect(blocks).toHaveLength(5);
expect(blocks.map((b) => markdownHtml(b)).join("")).toBe(markdownHtml(text));
expect(markdownBlocks("See [docs][d].\n\n[d]: https://example.com")).toHaveLength(1);
});
it("renders an HTML block that wraps Markdown whole", () => {
const text = "<details>\n<summary>Log</summary>\n\ntext\n\n</details>";
expect(markdownBlocks(text)).toEqual([text]);
const { container } = render(<Markdown text={text} cwd="/repo" />);
expect(container.querySelector("details p")?.textContent).toBe("text");
});
it("cuts only what streamed on after the blocks that ended", () => {
const before = markdownBlocks("One.\n\nTwo.\n\nThr");
const after = markdownBlocks("One.\n\nTwo.\n\nThree.\n\n- a", before);
expect(after).toEqual(["One.\n\n", "Two.\n\n", "Three.\n\n", "- a"]);
expect(after[0]).toBe(before[0]);
expect(markdownBlocks("Other.\n\nTwo.\n\nThree.", before)).toEqual(["Other.\n\n", "Two.\n\n", "Three."]);
});
it("keeps the blocks a chunk didn't change", () => {
const head = "Intro paragraph.\n\n```ts\nconst a = 1;\n```\n\n";
const { container, rerender } = render(<Markdown text={`${head}Half a sen`} cwd="/repo" />);
const intro = container.querySelector("p")!;
const code = container.querySelector("pre")!;
rerender(<Markdown text={`${head}Half a sentence, then more.`} cwd="/repo" />);
expect(container.querySelector("p")).toBe(intro);
expect(container.querySelector("pre")).toBe(code);
expect(container.querySelectorAll("p")[1]!.textContent).toBe("Half a sentence, then more.");
expect(screen.getAllByRole("button", { name: "Copy Code" })).toHaveLength(1);
rerender(<Markdown text="Something else" cwd="/repo" />);
expect(container.textContent?.trim()).toBe("Something else");
});
});
+5 -98
View File
@@ -6,7 +6,7 @@
* the drawn diagram hides the code block, and `onDiagramDrawn` lets the conversation
* follow the end when a diagram draws after the last streamed chunk.
*/
import { useLayoutEffect, useRef, useSyncExternalStore } from "react";
import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from "react";
import { Marked } from "marked";
import DOMPurify from "dompurify";
import { highlightInto } from "@/editor/lsp/markdown";
@@ -60,40 +60,6 @@ function purify(): ReturnType<typeof DOMPurify> {
return p;
}
/**
* `text` cut into its top-level blocks (a paragraph, a list, a fence…), each with the
* blank lines after it, so they join back into `text`. While a reply streams only its
* last block changes: the others keep their rendering. Reference links reach across
* blocks, and an HTML block can open in one token and close in another, so a text with
* either stays whole. The blocks before the last of `previous` (this text's blocks
* before it streamed on) have ended: when `text` still starts with them, only the rest
* is cut again.
*/
export function markdownBlocks(text: string, previous: readonly string[] = []): string[] {
let stable = 0;
let offset = 0;
while (stable < previous.length - 1 && text.startsWith(previous[stable]!, offset))
offset += previous[stable++]!.length;
if (stable) {
const rest = lexBlocks(text.slice(offset));
if (rest) return [...previous.slice(0, stable), ...rest];
}
return lexBlocks(text) ?? [text];
}
/** `text`'s blocks, or `null` when it has to render whole. */
function lexBlocks(text: string): string[] | null {
const tokens = marked.lexer(text);
if (Object.keys(tokens.links).length || tokens.some((t) => t.type === "html")) return null;
const blocks: string[] = [];
for (const t of tokens) {
if (t.type === "space" && blocks.length) blocks[blocks.length - 1] += t.raw;
else blocks.push(t.raw);
}
// The lexer can drop what it doesn't keep in a token (trailing whitespace); keep the text whole.
return blocks.join("") === text ? blocks : null;
}
export function markdownHtml(text: string): string {
let html = rendered.get(text);
if (html === undefined) {
@@ -218,48 +184,6 @@ function enhance(root: HTMLElement, cwd: string, images: Map<string, Promise<str
};
}
interface PaintedBlock {
source: string;
nodes: ChildNode[];
dispose: () => void;
}
/** Removes the painted blocks from `from` on. */
function clearBlocks(painted: PaintedBlock[], from: number) {
for (const block of painted.splice(from)) {
block.dispose();
block.nodes.forEach((n) => n.remove());
}
}
/**
* Brings `root` to `sources`: blocks whose source is unchanged keep their nodes (and
* whatever was drawn into them); the rest are rendered after them. The nodes go straight
* into `root`, as a single rendering would put them, so styles and selections see one
* message.
*/
function paintBlocks(
root: HTMLElement,
painted: PaintedBlock[],
sources: string[],
cwd: string,
images: Map<string, Promise<string>>,
) {
let same = 0;
while (same < painted.length && same < sources.length && painted[same]!.source === sources[same]) same++;
clearBlocks(painted, same);
sources.slice(same).forEach((source, i, rest) => {
const holder = document.createElement("div");
// A streaming reply changes its last block on every frame: kept, those would push
// the finished blocks of other messages out of the cache.
holder.innerHTML = i === rest.length - 1 && !rendered.has(source) ? renderHtml(source) : markdownHtml(source);
const dispose = enhance(holder, cwd, images);
const nodes = [...holder.childNodes];
root.append(...nodes);
painted.push({ source, nodes, dispose });
});
}
export function Markdown({
text,
cwd,
@@ -270,32 +194,14 @@ export function Markdown({
/** A diagram drew after the message rendered: the conversation may follow the end. */
onDiagramDrawn?: () => void;
}) {
const html = useMemo(() => markdownHtml(text), [text]);
const ref = useRef<HTMLDivElement>(null);
const images = useRef(new Map<string, Promise<string>>());
/** The blocks on screen, so a streamed chunk repaints only the blocks it changed. */
const painted = useRef<PaintedBlock[]>([]);
const paintedCwd = useRef(cwd);
/** The last drawing of each mermaid block, stood in while a changed one renders. */
const diagrams = useRef<Node[]>([]);
// Diagrams are drawn in the active theme's colours: a theme change redraws them.
const theme = useSyncExternalStore(subscribeTheme, themeKey, () => "");
useLayoutEffect(() => {
const root = ref.current;
if (!root) return;
if (paintedCwd.current !== cwd) {
clearBlocks(painted.current, 0);
paintedCwd.current = cwd;
}
const sources = markdownBlocks(
text,
painted.current.map((b) => b.source),
);
paintBlocks(root, painted.current, sources, cwd, images.current);
}, [text, cwd]);
useLayoutEffect(() => {
const blocks = painted.current;
return () => clearBlocks(blocks, 0);
}, []);
useLayoutEffect(() => (ref.current ? enhance(ref.current, cwd, images.current) : undefined), [html, cwd]);
// Mermaid diagrams: before paint, so cached ones don't flash their source. Only a
// draw that lands after the render needs the follow — cached ones are in place by
// the time the conversation's own render pass scrolls.
@@ -309,7 +215,7 @@ export function Markdown({
live = false;
pass.cancel();
};
}, [text, theme, onDiagramDrawn]);
}, [html, theme, onDiagramDrawn]);
return (
<div
ref={ref}
@@ -338,6 +244,7 @@ export function Markdown({
if (link) openPathLink(link);
}
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
+38 -115
View File
@@ -34,11 +34,38 @@
width: auto;
}
.header {
display: flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 6px 0 12px;
border-bottom: 1px solid var(--border);
flex: none;
min-width: 0;
overflow: hidden;
}
.header > * {
flex: none;
}
/* Shrinks ahead of the meters. */
.title {
flex: 0 1000 auto;
min-width: 0;
font-weight: 600;
color: var(--fg-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header > .headerSpacer {
flex: 1 1 0;
}
.providerIcon {
color: var(--fg-muted);
}
/* The task's state and its terminal and changes, just above the composer. */
/* The task's state, just above the composer; the header keeps only the actions. */
.statusBar {
display: flex;
align-items: center;
@@ -48,16 +75,6 @@
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.statusActions {
display: flex;
flex: none;
align-items: center;
gap: 2px;
}
.statusActions > .changesToggle {
height: var(--h-control-small);
padding: 0 4px;
}
.statusText {
flex: 1 1 0;
min-width: 0;
@@ -174,7 +191,8 @@
width: fit-content;
max-width: min(var(--session-card-width), 720px);
}
.userMessage > .messageHead {
.userMessage > .messageHead,
.userMessage > .messageFooter {
justify-content: flex-end;
}
/* The actions and time below a message, in flow so they never cover the next entry. */
@@ -239,18 +257,15 @@
-webkit-user-select: none;
user-select: none;
}
.messageFooter > .messageTime,
.messageHead > .messageTime {
.messageFooter > .messageTime {
opacity: 0;
}
/* A turn's final reply keeps its time and actions in view; your messages show theirs on hover. */
.conversationRow:nth-last-child(1 of [data-message]) .agentMessage .messageTime,
.conversationRow:nth-last-child(1 of [data-message]) .agentMessage .messageActions,
.conversationRow:nth-last-child(1 of [data-message]) .reply .messageActions,
.message:hover .messageTime,
.conversationRow:nth-last-child(1 of [data-message]) .messageFooter > .messageTime,
.message:hover .messageFooter > .messageTime,
.agentMessage:hover .messageFooter > .messageTime,
.message:focus-within .messageTime,
.message:focus-within .messageFooter > .messageTime,
.agentMessage:focus-within .messageFooter > .messageTime,
.conversationRow:nth-last-child(1 of [data-message]) .messageActions,
.message:hover .messageActions,
.reply:hover .messageActions,
.agentMessage:hover .messageActions,
@@ -365,23 +380,18 @@
.markdown :global(p:last-child) {
margin-bottom: 0;
}
.markdown {
/* A tint of the text colour: reads as code on any background, and a selection over
it doesn't leave light boxes the way an opaque near-white fill does. */
--md-code-bg: color-mix(in srgb, var(--fg) 7%, transparent);
}
.markdown :global(code) {
font-family: var(--font-mono);
font-size: var(--fs-small);
padding: 0 2px;
padding: 0 4px;
border-radius: 3px;
background: var(--md-code-bg);
background: var(--bg-elev);
}
.markdown :global(pre) {
margin: 0 0 8px;
padding: 8px 10px;
border-radius: var(--radius-small);
background: var(--md-code-bg);
background: var(--bg-elev);
overflow: auto;
}
.markdown :global(pre code) {
@@ -439,9 +449,6 @@
.markdown :global(.mermaid-output) {
overflow: hidden;
text-align: center;
/* Node labels are drawing, not text: selecting across a reply skips them. */
-webkit-user-select: none;
user-select: none;
}
.markdown :global([data-mermaid-state="error"] > .mermaid-output) {
margin-top: 6px;
@@ -1464,87 +1471,3 @@
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Reasoning while it streams (auto): bounded, its tail in view. */
.thoughtLive .thoughtBody {
max-height: calc(var(--h-row) * 4);
overflow: hidden;
display: flex;
flex-direction: column-reverse;
mask-image: linear-gradient(to bottom, transparent, black 40%);
}
/* A finished turn's work, folded under its message. */
.turnFold > summary {
cursor: pointer;
list-style: none;
}
.turnFold > summary::-webkit-details-marker {
display: none;
}
.turnFold[open] .processChevron {
transform: rotate(90deg);
}
.turnBody {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: 2px;
padding-left: 10px;
border-left: 1px solid var(--border);
}
/* Above the composer: what waits for the user, the plan and changes, what runs now. */
.tray {
display: flex;
flex-direction: column;
gap: 2px;
}
.tray:empty {
display: none;
}
.trayAsk {
min-width: 0;
}
.trayStrip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.trayChip {
display: inline-flex;
align-items: center;
gap: 6px;
height: var(--h-row);
padding: 0 var(--space-2);
border: none;
border-radius: var(--radius-row);
background: none;
color: var(--fg-secondary);
font: inherit;
font-size: var(--fs-small);
cursor: pointer;
}
.trayChip:hover,
.trayChip[aria-pressed="true"],
.trayChip[aria-expanded="true"] {
background: var(--bg-hover);
color: var(--fg);
}
.trayPlan {
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px var(--space-2) 4px calc(var(--space-2) * 2);
font-size: var(--fs-small);
}
.planCurrent {
color: var(--fg);
}
.trayActivity {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
File diff suppressed because it is too large. Load diff
+6 -17
View File
@@ -1,33 +1,22 @@
/**
* The tab strip's agent status indicator (#2233): a spinner while the task is busy, then a
* dot only while something waits for you (a finished reply not yet read, a question, an error).
*/
/** The tab strip's agent status indicator: spinner while the task is busy, dot once done (#2233). */
import { Badge, Spinner, tooltipAttrs } from "@/ui";
import { isBusy, statusLabel } from "./model";
import { useAgents } from "./store";
export function TaskStatusMark({ task }: { task: string }) {
const status = useAgents((s) => s.tasks.get(task)?.status);
const unread = useAgents((s) => s.unread.has(task));
if (!status) return null;
// Waiting for you is a dot, not a spinner: the task stops until you answer.
const busy = isBusy(status) && status !== "needsInput";
const tone =
status === "failed" || status === "limited"
? "danger"
: status === "needsInput"
? "warning"
: status === "ready" && unread
? "success"
: null;
if (!busy && !tone) return null;
const busy = isBusy(status);
const label = statusLabel(status);
return (
<span {...tooltipAttrs(label, undefined, true)} style={{ display: "inline-flex", alignItems: "center" }}>
{busy ? (
<Spinner size={10} variant="spokes" label={label} />
) : (
<Badge dot tone={tone!}>
<Badge
dot
tone={status === "ready" ? "success" : status === "failed" || status === "limited" ? "danger" : "neutral"}
>
{label}
</Badge>
)}
+40 -170
View File
@@ -23,7 +23,6 @@ function AgentRegistryPage(props: ComponentProps<typeof RegistryPage>) {
}
import { ADD_AGENT, providerOptions } from "./providerOptions";
import {
afterInstall,
afterRemove,
matchesQuery,
rowActions,
@@ -81,30 +80,19 @@ class FakeBackend implements RegistryBackend {
this.installRequests.push(request);
this.emit = onEvent;
return new Promise<void>((resolve, reject) => {
this.finish = () => {
this.registry = { ...this.registry, agents: afterInstall(this.registry.agents, id) };
resolve();
};
this.finish = resolve;
this.fail = reject;
});
}
async remove(id: string) {
this.removed.push(id);
this.registry = { ...this.registry, agents: afterRemove(this.registry.agents, id) };
}
cancel(request: RequestId) {
this.cancelled.push(request);
}
}
/** The registry agent's details, selecting it in the list first. */
const row = (name: string) => {
const shown = screen.queryByRole("region", { name });
if (shown) return shown;
fireEvent.mouseDown(screen.getByRole("option", { name: new RegExp(`^${name}`) }));
return screen.getByRole("region", { name });
};
const item = (name: string) => screen.getByRole("option", { name: new RegExp(`^${name}`) });
const row = (name: string) => screen.getByRole("listitem", { name });
describe("registry helpers", () => {
it("matches every word of the query against name, id, description and authors", () => {
@@ -188,35 +176,17 @@ describe("agent picker", () => {
});
});
describe("the ACP Registry in the agent list", () => {
/** The page, with the registry's installed agents as the machine's agents. */
const renderRegistry = (backend: FakeBackend, onChanged?: () => void) => {
const commands = new FakeCommands();
commands.providers.mockImplementation(async () =>
backend.registry.agents
.filter((a) => a.installed != null)
.map((a) => ({
...claudeProvider("x"),
id: a.id,
name: a.name,
loginHint: null,
commandEditable: false,
commandReadOnlyReason: "registry" as const,
source: { kind: "registry" as const, version: a.installed!, update: a.update ? a.version : null },
})),
);
return render(
<>
<AgentRegistryPage backend={backend} commandBackend={commands} onChanged={onChanged} />
<MessagesHost />
</>,
);
describe("Agents settings page", () => {
const renderRegistry = (...args: Parameters<typeof render>) => {
const result = render(...args);
fireEvent.click(screen.getByRole("tab", { name: "ACP Registry" }));
return result;
};
it("shows the built-in Copilot name and icon in its registry row", async () => {
const backend = new FakeBackend();
backend.registry = { agents: [agent({ id: "copilot", name: "GitHub Copilot" })], warning: null };
renderRegistry(backend);
const copilot = await screen.findByRole("option", { name: "GitHub Copilot" });
renderRegistry(<AgentRegistryPage backend={backend} />);
const copilot = await screen.findByRole("listitem", { name: "GitHub Copilot" });
const expected = document.createElement("div");
expected.innerHTML = iconMarkup("agent-copilot")!;
expect(copilot.querySelector("svg")?.innerHTML).toBe(expected.querySelector("svg")?.innerHTML);
@@ -225,20 +195,18 @@ describe("the ACP Registry in the agent list", () => {
it("lists registry agents, says they are third-party, and searches them", async () => {
const backend = new FakeBackend();
renderRegistry(backend);
renderRegistry(<AgentRegistryPage backend={backend} />);
await waitFor(() => expect(row("Amp")).toBeInTheDocument());
expect(screen.getByText(/third-party software/)).toBeInTheDocument();
expect(within(row("Amp")).getByRole("button", { name: "Install" })).toBeEnabled();
expect(within(row("Amp")).getByRole("button", { name: "Repository" })).toBeInTheDocument();
expect(within(item("Cline")).getByText("Update")).toBeInTheDocument();
expect(within(row("Cline")).getByText("ACP Registry · 3.0.70")).toBeInTheDocument();
expect(within(row("Cline")).getByText("3.0.74 is available")).toBeInTheDocument();
expect(within(row("Cline")).getByText("3.0.74 available")).toBeInTheDocument();
expect(within(row("Cline")).getByRole("button", { name: "Update" })).toBeInTheDocument();
expect(within(row("fast-agent")).getByRole("button", { name: "Remove" })).toBeInTheDocument();
expect(within(row("fast-agent")).queryByRole("button", { name: "Update" })).toBeNull();
fireEvent.change(screen.getByRole("textbox", { name: "Search agents" }), { target: { value: "frontier" } });
expect(screen.getAllByRole("option")).toHaveLength(1);
expect(screen.getAllByRole("listitem")).toHaveLength(1);
fireEvent.change(screen.getByRole("textbox", { name: "Search agents" }), { target: { value: "nothing" } });
expect(screen.getByText("No agents match")).toBeInTheDocument();
});
@@ -246,7 +214,7 @@ describe("the ACP Registry in the agent list", () => {
it("installs with progress, then reports the change", async () => {
const backend = new FakeBackend();
const onChanged = vi.fn();
renderRegistry(backend, onChanged);
renderRegistry(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
await waitFor(() => expect(row("Amp")).toBeInTheDocument());
fireEvent.click(within(row("Amp")).getByRole("button", { name: "Install" }));
expect(backend.installs).toEqual(["amp-acp"]);
@@ -258,16 +226,21 @@ describe("the ACP Registry in the agent list", () => {
await act(async () => backend.finish());
expect(onChanged).toHaveBeenCalledTimes(1);
await waitFor(() => expect(within(row("Amp")).getByRole("button", { name: "Remove" })).toBeEnabled());
expect(within(row("Amp")).getByText("ACP Registry · 0.10.0")).toBeInTheDocument();
expect(within(row("Amp")).getByText("Installed")).toBeInTheDocument();
expect(within(row("Amp")).getByRole("button", { name: "Remove" })).toBeEnabled();
});
it("asks before installing an agent whose download has no checksum", async () => {
const backend = new FakeBackend();
backend.registry = { agents: [{ ...AMP, noChecksum: true }], warning: null };
renderRegistry(backend);
renderRegistry(
<>
<AgentRegistryPage backend={backend} />
<MessagesHost />
</>,
);
await waitFor(() => expect(row("Amp")).toBeInTheDocument());
expect(within(row("Amp")).getByText(/No checksum provided/)).toBeInTheDocument();
expect(within(row("Amp")).getByText("No checksum provided")).toBeInTheDocument();
fireEvent.click(within(row("Amp")).getByRole("button", { name: "Install" }));
let dialog = await screen.findByRole("dialog");
@@ -284,7 +257,12 @@ describe("the ACP Registry in the agent list", () => {
it("installs an agent with a checksum without asking", async () => {
const backend = new FakeBackend();
renderRegistry(backend);
renderRegistry(
<>
<AgentRegistryPage backend={backend} />
<MessagesHost />
</>,
);
await waitFor(() => expect(row("Amp")).toBeInTheDocument());
expect(screen.queryByText("No checksum provided")).toBeNull();
fireEvent.click(within(row("Amp")).getByRole("button", { name: "Install" }));
@@ -294,7 +272,7 @@ describe("the ACP Registry in the agent list", () => {
it("shows why an install failed, cancels one on Cancel, and leaves it running when the page closes", async () => {
const backend = new FakeBackend();
const { unmount } = renderRegistry(backend);
const { unmount } = renderRegistry(<AgentRegistryPage backend={backend} />);
await waitFor(() => expect(row("Cline")).toBeInTheDocument());
fireEvent.click(within(row("Cline")).getByRole("button", { name: "Update" }));
await act(async () => backend.fail(new IpcError({ kind: "unavailable", message: "npm is required" })));
@@ -318,7 +296,7 @@ describe("the ACP Registry in the agent list", () => {
const backend = new FakeBackend();
const onChanged = vi.fn();
backend.registry = { agents: [{ ...AMP, installing: true }, CLINE], warning: null };
renderRegistry(backend, onChanged);
renderRegistry(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
await waitFor(() => expect(row("Amp")).toBeInTheDocument());
expect(within(row("Amp")).getByText("Installing Amp in the background…")).toBeInTheDocument();
// It is stopped from the background tasks, and nothing else starts meanwhile.
@@ -328,9 +306,7 @@ describe("the ACP Registry in the agent list", () => {
expect(within(row("Cline")).getByRole("button", { name: "Update" })).toBeDisabled();
backend.registry = { agents: [{ ...AMP, installed: AMP.version }, CLINE], warning: null };
await waitFor(() => expect(within(row("Amp")).getByRole("button", { name: "Remove" })).toBeInTheDocument(), {
timeout: 4000,
});
await waitFor(() => expect(within(row("Amp")).getByText("Installed")).toBeInTheDocument(), { timeout: 4000 });
expect(onChanged).toHaveBeenCalledTimes(1);
expect(within(row("Cline")).getByRole("button", { name: "Update" })).toBeEnabled();
});
@@ -338,11 +314,9 @@ describe("the ACP Registry in the agent list", () => {
it("removes an installed agent", async () => {
const backend = new FakeBackend();
const onChanged = vi.fn();
renderRegistry(backend, onChanged);
renderRegistry(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
await waitFor(() => expect(row("fast-agent")).toBeInTheDocument());
fireEvent.click(within(row("fast-agent")).getByRole("button", { name: "Remove" }));
const dialog = await screen.findByRole("dialog", { name: "Remove fast-agent" });
fireEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
await waitFor(() => expect(within(row("fast-agent")).getByRole("button", { name: "Install" })).toBeEnabled());
expect(backend.removed).toEqual(["fast-agent"]);
expect(onChanged).toHaveBeenCalledTimes(1);
@@ -351,12 +325,12 @@ describe("the ACP Registry in the agent list", () => {
it("falls back to the saved list with a warning, and reports a registry it cannot load", async () => {
const backend = new FakeBackend();
backend.registry = { agents: AGENTS, warning: "Could not reach the ACP Registry" };
const { unmount } = renderRegistry(backend);
const { unmount } = renderRegistry(<AgentRegistryPage backend={backend} />);
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Could not reach the ACP Registry"));
unmount();
backend.list.mockRejectedValueOnce(new IpcError({ kind: "io", message: "offline" }));
renderRegistry(backend);
renderRegistry(<AgentRegistryPage backend={backend} />);
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Cannot load the ACP Registry: offline"));
});
});
@@ -423,12 +397,12 @@ describe("Built-in executable overrides", () => {
component: () => <RegistryPage backend={backend} commandBackend={commands} onChanged={onChanged} />,
});
render(<SettingsDialog />);
return screen.findByRole("region", { name: "Claude Code" });
return screen.findByRole("region", { name: "Claude Code executable" });
};
const apply = () => screen.getByRole("button", { name: "Apply" });
const field = () => screen.getByRole("textbox", { name: "Executable" });
it("follows locale changes and renders login commands as inline code", async () => {
it("follows locale changes in tabs and renders login commands as inline code", async () => {
const commands = new FakeCommands();
commands.list = [
claudeProvider("claude"),
@@ -450,10 +424,14 @@ describe("Built-in executable overrides", () => {
await setLocale("zh");
});
expect(screen.getByRole("option", { name: "Codex 未找到" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "内置" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "ACP 注册表" })).toBeInTheDocument();
await act(async () => {
await setLocale("en");
});
expect(screen.getByRole("option", { name: "Codex Not found" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Built-in" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "ACP Registry" })).toBeInTheDocument();
} finally {
await act(async () => {
await setLocale("en");
@@ -675,111 +653,3 @@ describe("Built-in executable overrides", () => {
expect(commands.saved).toEqual([["claude", "/chosen/agent"]]);
});
});
describe("the agent list", () => {
let dispose = () => {};
beforeEach(() => openSettings(REGISTRY_PAGE));
afterEach(() => {
closeSettings();
dispose();
});
const open = async (commands: FakeCommands, backend = new FakeBackend()) => {
dispose = registerSettingsPage({
id: REGISTRY_PAGE,
title: "Agents",
component: () => <RegistryPage backend={backend} commandBackend={commands} />,
});
render(
<>
<SettingsDialog />
<MessagesHost />
</>,
);
await screen.findByRole("region", { name: "Claude Code" });
return backend;
};
const model = {
id: "model",
name: "Model",
description: null,
category: "model",
current: "sonnet",
values: [
{ value: "sonnet", name: "Sonnet", description: null },
{ value: "opus", name: "Opus", description: null },
],
via: "option",
} as AgentProvider["config"][number];
it("lists registry agents beside the built-ins, updates one and removes it", async () => {
const commands = new FakeCommands();
commands.list = [
claudeProvider("claude"),
{
...claudeProvider("cline"),
id: "cline",
name: "Cline",
commandEditable: false,
commandReadOnlyReason: "registry",
source: { kind: "registry", version: "3.0.70", update: "3.0.74" },
},
];
const backend = await open(commands);
fireEvent.mouseDown(screen.getByRole("option", { name: "Cline Update" }));
const details = await screen.findByRole("region", { name: "Cline" });
expect(within(details).getByText("ACP Registry · 3.0.70")).toBeInTheDocument();
fireEvent.click(within(details).getByRole("button", { name: "Update" }));
expect(backend.installs).toEqual(["cline"]);
await act(async () => backend.finish());
fireEvent.click(within(screen.getByRole("region", { name: "Cline" })).getByRole("button", { name: "Remove" }));
const dialog = await screen.findByRole("dialog", { name: "Remove Cline" });
fireEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
await waitFor(() => expect(backend.removed).toEqual(["cline"]));
});
it("lists every agent, installed first, and filters them", async () => {
const commands = new FakeCommands();
commands.list = [
claudeProvider("claude"),
{
...claudeProvider("codex"),
id: "codex",
name: "Codex",
defaultProgram: "codex",
commandOverridden: false,
installed: false,
},
];
await open(commands);
await waitFor(() => expect(screen.getByRole("option", { name: /^Amp/ })).toBeInTheDocument());
const names = screen.getAllByRole("option").map((o) => o.textContent?.trim());
expect(names).toEqual(["Claude Code", "Cline Update", "fast-agent", "Codex Not found", "Amp"]);
expect(screen.getByText("Installed")).toBeInTheDocument();
expect(screen.getByText("Available")).toBeInTheDocument();
fireEvent.mouseDown(screen.getByRole("option", { name: /^Amp/ }));
const amp = screen.getByRole("region", { name: "Amp" });
expect(within(amp).getByRole("button", { name: "Install" })).toBeEnabled();
expect(within(amp).queryByRole("region", { name: "Launch" })).toBeNull();
fireEvent.change(screen.getByRole("textbox", { name: "Search agents" }), { target: { value: "codex" } });
expect(screen.getAllByRole("option").map((o) => o.textContent?.trim())).toEqual(["Codex Not found"]);
expect(screen.queryByText("Installed")).toBeNull();
});
it("stages the settings new tasks start with", async () => {
const commands = new FakeCommands();
commands.list = [{ ...claudeProvider("claude"), config: [model] }];
await open(commands);
const select = screen.getByRole("button", { name: "Model" });
expect(select).toHaveTextContent("Agent's default (Sonnet)");
fireEvent.click(select);
fireEvent.click(await screen.findByRole("option", { name: "Opus" }));
expect(screen.getByRole("button", { name: "Model" })).toHaveTextContent("Opus");
});
it("says when an agent's settings aren't known yet", async () => {
const commands = new FakeCommands();
commands.list = [claudeProvider("claude")];
await open(commands);
expect(screen.getByText(/after its first session/)).toBeInTheDocument();
});
});
-31
View File
@@ -1,31 +0,0 @@
/** Settings › Agents, with the defaults filled in. */
import { useMemo } from "react";
import type { AgentSettings } from "@/bindings";
import { useProject } from "@/platform/project";
export type ResolvedAgentSettings = Required<AgentSettings>;
export const AGENT_SETTINGS_DEFAULTS: ResolvedAgentSettings = {
showContextUsage: true,
showWorkTime: true,
showUsageLimits: true,
showCost: false,
foldTurns: true,
smoothText: true,
thinking: "auto",
toolCalls: "grouped",
expandToolOutput: false,
sendWith: "enter",
whileRunning: "queue",
defaults: {},
};
export function resolveAgentSettings(s: AgentSettings | null | undefined): ResolvedAgentSettings {
const set = Object.fromEntries(Object.entries(s ?? {}).filter(([, v]) => v !== undefined && v !== null));
return { ...AGENT_SETTINGS_DEFAULTS, ...set };
}
export function useAgentSettings(): ResolvedAgentSettings {
const s = useProject((p) => p.settings?.agents);
return useMemo(() => resolveAgentSettings(s), [s]);
}
+13 -108
View File
@@ -25,7 +25,6 @@ import {
} from "./composerModel";
import {
age,
appendEntry,
attentionChange,
branchLabel,
entriesKey,
@@ -93,30 +92,12 @@ describe("mergeMatches", () => {
describe("agent composer", () => {
it("completes @files and /commands anywhere at a word boundary", () => {
expect(completionAt("fix @src/le", 11)).toEqual({
kind: "file",
query: "src/le",
from: 4,
to: 11,
});
expect(completionAt("/rev", 4)).toEqual({
kind: "command",
query: "rev",
from: 0,
to: 4,
});
expect(completionAt("run /rev", 8)).toEqual({
kind: "command",
query: "rev",
from: 4,
to: 8,
});
expect(completionAt("fix @src/le", 11)).toEqual({ kind: "file", query: "src/le", from: 4, to: 11 });
expect(completionAt("/rev", 4)).toEqual({ kind: "command", query: "rev", from: 0, to: 4 });
expect(completionAt("run /rev", 8)).toEqual({ kind: "command", query: "rev", from: 4, to: 8 });
expect(completionAt("a@b", 3)).toBeNull();
const c = completionAt("see @le and", 7)!;
expect(applyCompletion("see @le and", c, "@src/lexer.rs")).toEqual({
text: "see @src/lexer.rs and",
caret: 18,
});
expect(applyCompletion("see @le and", c, "@src/lexer.rs")).toEqual({ text: "see @src/lexer.rs and", caret: 18 });
const cmds = [
{ name: "init", description: "", hint: null },
{ name: "review", description: "", hint: null },
@@ -172,11 +153,7 @@ describe("agent composer", () => {
],
images: [],
});
expect(prompt).toEqual({
text: "look at @src/a.rs:3-5 and @b.rs",
files: ["/w/src/a.rs", "/w/b.rs"],
images: [],
});
expect(prompt).toEqual({ text: "look at @src/a.rs:3-5 and @b.rs", files: ["/w/src/a.rs", "/w/b.rs"], images: [] });
expect(mentionOf("a.rs", { from: 3, to: 3 })).toBe("@a.rs:3");
// A folder counts while `@src/` is left as typed, not once a file inside it was picked.
const folder = { path: "/w/src", rel: "src/" };
@@ -205,10 +182,7 @@ describe("agent composer", () => {
const permissions = option("allow_all", ["off", "on"], "off");
permissions.category = "permissions";
expect(mainOptions([...config, permissions]).map((o) => o.id)).toEqual(["model", "mode", permissions.id]);
const approvals = {
...option("openidea.cursor.approval", ["ask", "bypass"], "ask"),
category: "approval",
};
const approvals = { ...option("openidea.cursor.approval", ["ask", "bypass"], "ask"), category: "approval" };
expect(mainOptions([...config, permissions, approvals]).map((o) => o.id)).toEqual([
"model",
"mode",
@@ -229,9 +203,7 @@ describe("agent composer", () => {
"https://agentclientprotocol.com/protocol/session-modes#autopilot",
].map(modeIcon),
).toEqual(["lock", "edit", "todo", "run", "unlock", "edit", "eye", "lock", "run", "run"]);
expect(bypassDefaults([...config, permissions])).toEqual({
allow_all: "on",
});
expect(bypassDefaults([...config, permissions])).toEqual({ allow_all: "on" });
expect(usageLabel({ used: 24_000, size: 200_000 })).toEqual({
short: "12%",
detail: "24k / 200k tokens",
@@ -252,30 +224,14 @@ describe("usage meters", () => {
it("names usage-limit windows and says when they reset", () => {
const hour = 3_600_000;
expect(
limitLabel(
{
window: "five_hour",
usedPercent: 19.4,
resetsMs: 2 * hour + 51 * 60_000,
},
0,
),
).toEqual({
expect(limitLabel({ window: "five_hour", usedPercent: 19.4, resetsMs: 2 * hour + 51 * 60_000 }, 0)).toEqual({
short: "5h 19%",
percent: 19,
detail: "5-hour limit: 19% used, resets in 2h 51m",
});
expect(
limitLabel(
{
window: "seven_day",
usedPercent: 120,
resetsMs: 5 * 24 * hour + 3 * hour,
},
0,
).detail,
).toBe("Weekly limit: 100% used, resets in 5d 3h");
expect(limitLabel({ window: "seven_day", usedPercent: 120, resetsMs: 5 * 24 * hour + 3 * hour }, 0).detail).toBe(
"Weekly limit: 100% used, resets in 5d 3h",
);
expect(limitLabel({ window: "spend_limit", usedPercent: 7, resetsMs: null }, 0)).toEqual({
short: "spend_limit 7%",
percent: 7,
@@ -326,22 +282,6 @@ describe("agents model", () => {
expect(upsertEntry([a], 3, b)).toBeNull();
});
it("appends streamed text from where the copy here stands", () => {
const u: AgentEntry = { type: "user", text: "q", attachments: [] };
const a: AgentEntry = { type: "agent", text: "He" };
expect(appendEntry([u, a], 1, 2, "llo")).toEqual([u, { type: "agent", text: "Hello" }]);
// Loaded while it streamed: part of the text is already here.
expect(appendEntry([u, { type: "agent", text: "Hel" }], 1, 2, "llo")?.[1]).toEqual({
type: "agent",
text: "Hello",
});
const whole = [u, { type: "agent", text: "Hello" } as AgentEntry];
expect(appendEntry(whole, 1, 2, "llo")).toBe(whole);
expect(appendEntry([u, { type: "agent", text: "H" }], 1, 2, "llo")).toBeNull();
expect(appendEntry([u, a], 0, 0, "x")).toBeNull();
expect(appendEntry([u, a], 2, 0, "x")).toBeNull();
});
it("notifies on finish, failure and questions only", () => {
expect(attentionChange("running", "ready")).toBe("finished");
expect(attentionChange("running", "failed")).toBe("failed");
@@ -425,17 +365,9 @@ describe("turns", () => {
describe("retryParts", () => {
it("counts down to the next attempt and shows the limit when known", () => {
const retry = {
attempt: 3,
maxRetries: 10,
retryAtMs: 5_400,
error: "Connection error.",
};
const retry = { attempt: 3, maxRetries: 10, retryAtMs: 5_400, error: "Connection error." };
expect(retryParts(retry, 1_000)).toEqual({ attempt: "3/10", wait: 5 });
expect(retryParts({ ...retry, maxRetries: null }, 9_000)).toEqual({
attempt: "3",
wait: 0,
});
expect(retryParts({ ...retry, maxRetries: null }, 9_000)).toEqual({ attempt: "3", wait: 0 });
});
});
@@ -453,30 +385,3 @@ describe("typed path references", () => {
expect(resolveTyped("C:\\r", "../../x")).toBe("C:/x");
});
});
describe("new task defaults", () => {
it("start a draft with the agent's defaults until the draft picks", async () => {
const { draftConfig } = await import("./store");
const option = (id: string, current: string, values: string[]) =>
({
id,
name: id,
description: null,
category: id,
current,
values: values.map((value) => ({ value, name: value, description: null })),
via: "option",
}) as AgentConfigOption;
const provider = {
id: "claude",
config: [option("model", "sonnet", ["sonnet", "opus"]), option("mode", "default", ["default", "plan"])],
} as unknown as Parameters<typeof draftConfig>[0];
const draft = { provider: "claude", config: {}, isolation: "local", base: null } as unknown as Parameters<
typeof draftConfig
>[1];
const current = (d: typeof draft, defaults: Record<string, string>) =>
draftConfig(provider, d, defaults).map((o) => o.current);
expect(current(draft, { model: "opus", mode: "nope" })).toEqual(["opus", "default"]);
expect(current({ ...draft, config: { model: "sonnet" } }, { model: "opus" })).toEqual(["sonnet", "default"]);
});
});
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sendKeyAction } from "./Composer";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { AgentCommand, AgentProvider } from "@/bindings";
@@ -284,23 +283,3 @@ describe("workspace task project", () => {
await vi.dynamicImportSettled();
});
});
describe("sendKeyAction", () => {
const key = (k: Partial<{ shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }> = {}) => ({
key: "Enter",
shiftKey: false,
metaKey: false,
ctrlKey: false,
...k,
});
it("sends with Enter, or with the modifier, the way the settings say", () => {
expect(sendKeyAction(key(), "enter", "queue")).toBe("queue");
expect(sendKeyAction(key({ metaKey: true }), "enter", "queue")).toBe("now");
expect(sendKeyAction(key({ shiftKey: true }), "enter", "queue")).toBeNull();
expect(sendKeyAction(key(), "enter", "steer")).toBe("now");
expect(sendKeyAction(key(), "modEnter", "queue")).toBeNull();
expect(sendKeyAction(key({ ctrlKey: true }), "modEnter", "queue")).toBe("queue");
expect(sendKeyAction(key({ metaKey: true, shiftKey: true }), "modEnter", "queue")).toBe("now");
expect(sendKeyAction({ ...key(), key: "a" }, "enter", "queue")).toBeNull();
});
});
+9 -17
View File
@@ -19,22 +19,21 @@ import {
toolWindowService,
} from "@/workbench/contributions";
import { AgentsStatus } from "./AgentsStatus";
import { AGENTS_GROUP, HOME_KIND, REGISTRY_PAGE, SESSION_KIND, TASK_DRAG, TOOL_WINDOW, providerIcon } from "./model";
import { HOME_KIND, REGISTRY_PAGE, SESSION_KIND, TASK_DRAG, TOOL_WINDOW } from "./model";
import { sendPaths, sendSelection } from "./references";
import { focusedSession } from "./sessionFocus";
import { connect, disconnect, openHome, useAgents } from "./store";
import { TaskStatusMark } from "./TaskStatusMark";
import { taskActions, useAgentsHeaderMenu } from "./taskActions";
import { taskActions } from "./taskActions";
import { replyActions } from "./replyActions";
/** Settings › Agents › Coding Agents (also used by the Settings window). */
/** Settings › Tools › Agents (also used by the Settings window). */
export function activateSettingsPages(): () => void {
return registerSettingsPage({
id: REGISTRY_PAGE,
title: "Coding Agents",
group: AGENTS_GROUP,
// First in Agents, which sits after Languages & Frameworks and before Tools.
order: 450,
title: "Agents",
group: "Tools",
order: 500,
keywords: ["agent", "acp", "registry", "install", "update", "coding agent"],
component: lazyComponent(() => import("./AgentRegistryPage").then((m) => m.AgentRegistryPage)),
});
@@ -74,8 +73,7 @@ export function activate(): () => void {
anchor: "right",
order: 0.5,
component: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsView)),
headerMenu: useAgentsHeaderMenu,
plainTitle: true,
headerContent: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsLocationActions)),
headerActions: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsHeaderActions)),
useBadge: () => {
const n = useAgents(
@@ -91,10 +89,7 @@ export function activate(): () => void {
const data = input.data as { task: string; title?: string };
return useAgents.getState().tasks.get(data.task)?.title ?? data.title ?? "Agent Task";
},
icon: (input) => {
const task = useAgents.getState().tasks.get((input.data as { task: string }).task);
return task ? providerIcon(task.provider) : "toolwindow-agents";
},
icon: () => "toolwindow-agents",
// Busy / done marker in the tab's trailing slot (#2233).
trailing: (input) => createElement(TaskStatusMark, { task: (input.data as { task: string }).task }),
// Tabs of tasks deleted meanwhile are closed when the tasks load (`connect`).
@@ -106,10 +101,7 @@ export function activate(): () => void {
kind: HOME_KIND,
component: lazyComponent(() => import("./HomeEditor").then((m) => m.HomeEditor)),
title: () => "New Agent Task",
icon: (input) => {
const task = useAgents.getState().tasks.get((input.data as { task: string }).task);
return task ? providerIcon(task.provider) : "toolwindow-agents";
},
icon: () => "toolwindow-agents",
restorable: true,
}),
// An explicit editor drop sets the placement for subsequent sessions too.
+11 -3
View File
@@ -1,4 +1,4 @@
import { act, render, waitFor } from "@testing-library/react";
import { act, fireEvent, render, waitFor } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import type { AgentEntry, AgentTask } from "@/bindings";
@@ -73,7 +73,7 @@ it("redraws a diagram when the theme changes", async () => {
}
});
it("follows the end when a diagram draws after the last update", async () => {
it.each([false, true])("preserves scroll ownership when a diagram finishes (away: %s)", async (away) => {
// The diagram is still rendering when the conversation arrives at its end.
let draw!: () => void;
mermaid.render.mockReset().mockImplementation(
@@ -94,8 +94,16 @@ it("follows the end when a diagram draws after the last update", async () => {
const scroller = block.closest<HTMLElement>("div[tabindex='-1']")!;
Object.defineProperty(scroller, "scrollHeight", { value: 1000, configurable: true });
expect(scroller.scrollTop).toBe(0);
if (away) {
Object.defineProperty(scroller, "clientHeight", { value: 400, configurable: true });
scroller.scrollTop = 600;
fireEvent.scroll(scroller);
fireEvent.wheel(scroller, { deltaY: -4 });
scroller.scrollTop = 588;
fireEvent.scroll(scroller);
}
// The draw lands after the last streamed chunk: the viewport must follow it.
await act(async () => draw());
await waitFor(() => expect(block).toHaveAttribute("data-mermaid-state", "ok"));
expect(scroller.scrollTop).toBe(1000);
expect(scroller.scrollTop).toBe(away ? 588 : 1000);
});
+3 -25
View File
@@ -7,10 +7,8 @@ export const HOME_KEY = "agent:new";
export const TOOL_WINDOW = "agents";
/** MIME of a dragged agent task (a tree row or the docked conversation bar): the payload is the task id. */
export const TASK_DRAG = "application/x-openidea-agent-task";
/** Settings › Agents › Coding Agents: the agents this machine runs. */
export const REGISTRY_PAGE = "agents.list";
/** The Settings folder holding the agent pages. */
export const AGENTS_GROUP = "Agents";
/** Settings › Tools › Agents: the ACP Registry. */
export const REGISTRY_PAGE = "tools.agents";
export const sessionKey = (task: string) => `agent:${task}`;
@@ -97,23 +95,6 @@ export function upsertEntry(entries: AgentEntry[], index: number, entry: AgentEn
return next;
}
/**
* `entries` with `text` appended to the message at `index`, which read its first `from`
* UTF-16 units when the backend last sent it; `null` when the copy here can't take it (a
* gap, or another kind of entry), so the conversation is loaded again. A copy loaded while
* the text streamed may already hold some of it.
*/
export function appendEntry(entries: AgentEntry[], index: number, from: number, text: string): AgentEntry[] | null {
const entry = entries[index];
if (!entry || (entry.type !== "agent" && entry.type !== "thought")) return null;
const have = entry.text.length;
if (have < from) return null;
if (have >= from + text.length) return entries;
const next = entries.slice();
next[index] = { ...entry, text: entry.text + text.slice(have - from) };
return next;
}
/** "agent/fix-lexer" → "fix-lexer". */
export const branchLabel = (branch: string | null) => (branch ?? "").replace(/^agent\//, "");
@@ -163,10 +144,7 @@ export function replyTimes(entries: AgentEntry[]): Map<number, ReplyTimes> {
entries.forEach((entry, i) => {
if (entry.type === "user" && !entry.steered) current = entry;
else if (entry.type === "agent" && current)
out.set(i, {
sentMs: current.sentMs ?? null,
doneMs: current.doneMs ?? null,
});
out.set(i, { sentMs: current.sentMs ?? null, doneMs: current.doneMs ?? null });
});
return out;
}
+2 -2
View File
@@ -132,7 +132,7 @@ export const replyActions: Action[] = [
},
{
id: "agents.forkReply",
title: "Fork Conversation from Here",
title: "Branch Conversation from Here",
category: "Agents",
icon: "branch",
hidden: true,
@@ -140,7 +140,7 @@ export const replyActions: Action[] = [
const resolved = resolveReply(arg);
if (!resolved?.operations.fork) return;
const { task, reply } = resolved;
return runReplyOperation(task.id, "Couldn't fork the task", () => forkTask(task.id, reply.turn + 1));
return runReplyOperation(task.id, "Couldn't copy the task", () => forkTask(task.id, reply.turn + 1));
},
},
];
-70
View File
@@ -1,70 +0,0 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { REVEAL_HORIZON_MS, revealStep, useRevealedText } from "./reveal";
describe("revealStep", () => {
it("drains the backlog over the horizon, at least a character a frame", () => {
expect(revealStep(0, 16)).toBe(0);
expect(revealStep(300, 15)).toBe(30);
expect(revealStep(3, 1)).toBe(1);
expect(revealStep(300, REVEAL_HORIZON_MS)).toBe(300);
expect(revealStep(300, 10_000)).toBe(300);
expect(revealStep(300, 0)).toBe(1);
});
});
describe("useRevealedText", () => {
let frames: FrameRequestCallback[] = [];
let now = 0;
beforeEach(() => {
frames = [];
now = 0;
vi.spyOn(performance, "now").mockImplementation(() => now);
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
vi.stubGlobal("cancelAnimationFrame", () => {});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const frame = (ms: number) =>
act(() => {
now += ms;
const run = frames;
frames = [];
run.forEach((cb) => cb(now));
});
it("reveals appended text over frames while live", () => {
const { result, rerender } = renderHook(({ text, live }) => useRevealedText(text, live), {
initialProps: { text: "Hi", live: true },
});
expect(result.current).toBe("Hi");
rerender({ text: `Hi${"x".repeat(100)}`, live: true });
expect(result.current).toBe("Hi");
frame(15);
expect(result.current.length).toBe(12);
frame(200);
expect(result.current.length).toBe(102);
});
it("never paints half an emoji", () => {
const { result, rerender } = renderHook(({ text }) => useRevealedText(text, true), {
initialProps: { text: "a" },
});
rerender({ text: "a👋" });
frame(1);
expect(result.current).toBe("a👋");
});
it("shows text whole once it stops streaming or is replaced", () => {
const { result, rerender } = renderHook(({ text, live }) => useRevealedText(text, live), {
initialProps: { text: "Hi", live: true },
});
rerender({ text: "Hi there", live: true });
rerender({ text: "Hi there", live: false });
expect(result.current).toBe("Hi there");
rerender({ text: "Other", live: true });
expect(result.current).toBe("Other");
});
});
-57
View File
@@ -1,57 +0,0 @@
/**
* Paced reveal of a streaming reply. Chunks arrive in lumps (one flush per 40 ms, holding
* whatever the model produced meanwhile); painting each as it lands makes the lumps show.
* Arrival moves the target instead, and each frame reveals a share of the backlog, so a
* burst catches up faster rather than jumping. The store keeps the whole text: only what
* is painted is paced.
*/
import { useEffect, useState } from "react";
/** The backlog is drained over this long. Longer reads smoother but trails the model. */
export const REVEAL_HORIZON_MS = 150;
/** A frame stalled longer than this (a background tab) counts as this long. */
const MAX_FRAME_MS = 250;
/** Characters to reveal in a frame `elapsed` ms after the last, at least one while behind. */
export function revealStep(backlog: number, elapsed: number, horizon = REVEAL_HORIZON_MS): number {
if (backlog <= 0) return 0;
const ms = Math.min(Math.max(elapsed, 0), MAX_FRAME_MS);
if (ms >= horizon) return backlog;
return Math.min(backlog, Math.max(1, Math.ceil((backlog * ms) / horizon)));
}
/** `count` moved past a lone UTF-16 high surrogate, so an emoji never paints half. */
function boundary(text: string, count: number): number {
const c = text.charCodeAt(count - 1);
return c >= 0xd800 && c <= 0xdbff ? Math.min(count + 1, text.length) : count;
}
/**
* The part of `text` to paint. While `live`, appended text is revealed at a pace; a text
* that isn't the painted one continued (another message, an edit), or one that stops
* being live, is shown whole at once.
*/
export function useRevealedText(text: string, live: boolean): string {
const [state, setState] = useState({ text, count: text.length });
let count = state.count;
if (state.text !== text || (!live && count !== text.length)) {
count = live && text.startsWith(state.text.slice(0, count)) ? Math.min(count, text.length) : text.length;
setState({ text, count });
}
const behind = count < text.length;
useEffect(() => {
if (!behind) return;
let last = performance.now();
let frame = requestAnimationFrame(function tick(now) {
const elapsed = now - last;
last = now;
setState((s) => ({
...s,
count: boundary(s.text, s.count + revealStep(s.text.length - s.count, elapsed)),
}));
frame = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(frame);
}, [behind]);
return behind ? text.slice(0, count) : text;
}
+108 -96
View File
@@ -110,7 +110,6 @@ afterEach(() => {
beforeEach(() => {
disposeActions = registerActions([...replyActions, ...taskActions]);
useProject.setState({ settings: null });
backend.entryError = null;
backend.entryWait = null;
disconnect();
@@ -132,7 +131,6 @@ beforeEach(() => {
describe("conversation rows", () => {
it("keeps authors above messages and their folded work with matching icon-only actions", async () => {
useProject.setState({ settings: { agents: { foldTurns: false } } as never });
const user = (text: string): AgentEntry => ({
type: "user",
text,
@@ -160,8 +158,7 @@ describe("conversation rows", () => {
expect(author.parentElement?.nextElementSibling?.firstElementChild).toHaveClass(styles.agentMessage!);
const quote = within(stream()).getAllByRole("button", { name: "Quote Reply" });
const copy = within(stream()).getAllByRole("button", { name: "Copy Markdown" });
// One per turn: only a turn's final reply carries actions.
expect(quote).toHaveLength(2);
expect(quote).toHaveLength(3);
for (const button of [...quote, ...copy]) {
expect(button.textContent).toBe("");
expect(button.querySelector("svg path")).not.toBeNull();
@@ -212,8 +209,7 @@ describe("conversation rows", () => {
},
);
it("puts the time and hover actions under a turn's final reply only", async () => {
useProject.setState({ settings: { agents: { foldTurns: false } } as never });
it("anchors every agent footer with its time before the hover actions", async () => {
backend.entries.set("A", [
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: 13000 },
{ type: "agent", text: "First reply" },
@@ -223,19 +219,17 @@ describe("conversation rows", () => {
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
await screen.findByText("Final reply");
const [first, last] = stream().querySelectorAll(`.${styles.agentMessage}`);
expect(first?.children).toHaveLength(1);
const [card, footer] = last!.children;
expect(card).toHaveClass(styles.agentCard!);
expect(footer).toHaveClass(styles.messageFooter!);
expect(footer?.firstElementChild).toHaveClass(styles.messageTime!);
expect(footer?.firstElementChild).toHaveTextContent("12s");
expect(footer?.lastElementChild).toHaveClass(styles.messageActions!);
// Your message keeps its actions and time beside "You", above it.
expect(stream().querySelector(`.${styles.userMessage} > .${styles.messageFooter}`)).toBeNull();
expect(
stream().querySelector(`.${styles.userMessage} > .${styles.messageHead} .${styles.messageActions}`),
).not.toBeNull();
const messages = stream().querySelectorAll(`.${styles.agentMessage}`);
expect(messages).toHaveLength(2);
for (const message of messages) {
const [card, footer] = message.children;
expect(card).toHaveClass(styles.agentCard!);
expect(footer).toHaveClass(styles.messageFooter!);
expect(footer?.firstElementChild).toHaveClass(styles.messageTime!);
expect(footer?.firstElementChild).toHaveTextContent("12s");
expect(footer?.lastElementChild).toHaveClass(styles.messageActions!);
}
expect(stream().querySelector(`.${styles.userMessage} > .${styles.messageFooter}`)).not.toBeNull();
});
it("builds only the requested tail, with absolute indexes", () => {
@@ -257,7 +251,7 @@ describe("process folding", () => {
]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const summary = await screen.findByText("Reasoning · Read 2 files");
const summary = await screen.findByText("Reasoning · 2 tool calls");
const fold = summary.closest("details")!;
expect(fold.open).toBe(false);
// Folded content is rendered once opened, a level at a time.
@@ -274,50 +268,6 @@ describe("process folding", () => {
});
});
describe("turn folding", () => {
it("folds a finished turn's work under its message and keeps the reply", async () => {
backend.entries.set("A", [
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: 13000 },
{ type: "thought", text: "think" },
tool("one"),
{ type: "agent", text: "Interim note" },
tool("two", { kind: "execute" }),
{ type: "agent", text: "Final reply" },
]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
await screen.findByText("Final reply");
const summary = screen.getByText("Reasoning · Ran 1 command · Read 1 file · 1 message");
const fold = summary.closest("details")!;
expect(fold).toHaveClass(styles.turnFold!);
expect(fold).toHaveTextContent("12s");
expect(screen.queryByText("Interim note")).toBeNull();
fold.open = true;
fireEvent(fold, new Event("toggle"));
expect(await screen.findByText("Interim note")).toBeInTheDocument();
});
it("leaves the turn the agent works on open", async () => {
backend.entries.set("A", [
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: null },
tool("one"),
{ type: "agent", text: "Interim note" },
]);
useAgents.setState({
entries: new Map(backend.entries),
tasks: new Map(useAgents.getState().tasks).set("A", {
...useAgents.getState().tasks.get("A")!,
status: "running",
}),
});
render(<SessionEditor input={input("A")} />);
expect(await screen.findByText("Interim note")).toBeInTheDocument();
expect(stream().querySelector(`.${styles.turnFold}`)).toBeNull();
// The turn isn't over: its latest message is no final reply yet.
expect(stream().querySelector(`.${styles.agentMessage} > .${styles.messageFooter}`)).toBeNull();
});
});
describe("folded content", () => {
it("is rendered for Find in Conversation, so its text is found", async () => {
backend.entries.set("A", [
@@ -327,7 +277,7 @@ describe("folded content", () => {
]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const summary = await screen.findByText("Read 2 files");
const summary = await screen.findByText("2 tool calls");
expect(screen.queryByText("needle in a tool output")).toBeNull();
fireEvent.focus(summary);
act(() => focusedSession()!.openFind());
@@ -357,6 +307,89 @@ describe("message tags", () => {
});
describe("scrolling", () => {
it.each(["wheel", "touch", "ArrowUp", "PageUp", "Home", "Shift+Space"])(
"releases following before an upward %s scroll, even within the bottom threshold",
async (method) => {
useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]) });
render(<SessionEditor input={input("A")} />);
const el = stream();
let height = 1000;
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
el.scrollTop = 600;
fireEvent.scroll(el);
if (method === "wheel") fireEvent.wheel(el, { deltaY: -4 });
if (method === "touch") {
fireEvent.touchStart(el, { touches: [{ clientY: 100 }] });
fireEvent.touchMove(el, { touches: [{ clientY: 110 }] });
}
if (["ArrowUp", "PageUp", "Home"].includes(method)) fireEvent.keyDown(el, { key: method });
if (method === "Shift+Space") fireEvent.keyDown(el, { key: " ", shiftKey: true });
// A chunk can land before the browser dispatches the scroll event.
height = 1004;
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]) }));
expect(el.scrollTop).toBe(600);
el.scrollTop = 588;
fireEvent.scroll(el);
// A queued event at the same offset must not re-enable following near the bottom.
fireEvent.scroll(el);
el.scrollTop = 592;
fireEvent.scroll(el);
height = 1008;
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]) }));
expect(el.scrollTop).toBe(592);
expect(screen.getByRole("button", { name: "Jump to Latest" })).toBeInTheDocument();
// Scrolling down to the end resumes following.
fireEvent.wheel(el, { deltaY: 12 });
el.scrollTop = 608;
fireEvent.scroll(el);
height = 1100;
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Latest" }]]]) }));
expect(el.scrollTop).toBe(1100);
},
);
it("does not move the viewport while the scrollbar is held during streaming", () => {
useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]) });
render(<SessionEditor input={input("A")} />);
const el = stream();
let height = 1000;
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
el.scrollTop = 600;
fireEvent.scroll(el);
fireEvent.pointerDown(el);
height = 1100;
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]) }));
expect(el.scrollTop).toBe(600);
el.scrollTop = 588;
fireEvent.scroll(el);
fireEvent.pointerUp(window);
height = 1200;
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]) }));
expect(el.scrollTop).toBe(588);
});
it("keeps following when an inner tool output consumes the upward wheel", () => {
const transcript: AgentEntry[] = [tool("live", { status: "running", output: "Tool output" })];
useAgents.setState({ entries: new Map([["A", transcript]]) });
render(<SessionEditor input={input("A")} />);
const el = stream();
const output = screen.getByText("Tool output");
let height = 1000;
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
output.style.overflowY = "auto";
output.scrollTop = 100;
el.scrollTop = 600;
fireEvent.scroll(el);
fireEvent.wheel(output, { deltaY: -4 });
height = 1100;
act(() => useAgents.setState({ entries: new Map([["A", [...transcript, { type: "agent", text: "Streaming" }]]]) }));
expect(el.scrollTop).toBe(1100);
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
});
it("hides Jump to Latest and follows new replies after retracting the conversation", async () => {
render(<SessionEditor input={input("A")} />);
const el = stream();
@@ -482,7 +515,7 @@ describe("tool calls", () => {
const output = await screen.findByText("Reading files…");
expect(output.closest("details")!.open).toBe(true);
expect(output.closest(`.${styles.processRun}`)).toBeNull();
expect(screen.getByText("Read 2 files").closest("details")!.open).toBe(false);
expect(screen.getByText("2 tool calls").closest("details")!.open).toBe(false);
},
);
@@ -491,7 +524,7 @@ describe("tool calls", () => {
backend.entries.set("A", [edit("one"), edit("two"), { type: "agent", text: "Answer A" }]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const summary = await screen.findByText("Edited 1 file");
const summary = await screen.findByText("2 tool calls");
expect(summary.closest("details")!.open).toBe(false);
expect(screen.getByText("+4")).toBeInTheDocument();
expect(screen.getByText("−2")).toBeInTheDocument();
@@ -690,7 +723,7 @@ describe("a pending AskUserQuestion", () => {
});
});
describe("a pending permission request above the composer", () => {
describe("a pending permission request in the conversation", () => {
const command = 'python3 -c \'from pathlib import Path\np = Path("note.txt")\np.write_text("one\\ntwo")\'';
const options = [
{ id: "opaque-once", name: "Allow Once", kind: "allow_once" },
@@ -718,8 +751,7 @@ describe("a pending permission request above the composer", () => {
const card = await screen.findByRole("region", { name: "codex wants to run a command" });
expect(screen.getAllByRole("region", { name: "codex wants to run a command" })).toHaveLength(1);
expect(screen.queryByRole("alert")).toBeNull();
expect(stream()).not.toContainElement(card);
expect(card.closest(`.${styles.tray}`)).not.toBeNull();
expect(stream()).toContainElement(card);
expect(card.querySelector("pre")?.textContent).toBe(command);
const buttons = within(card).getAllByRole("button");
expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual([
@@ -733,7 +765,7 @@ describe("a pending permission request above the composer", () => {
expect(buttons[1]?.title).toBe(options[1]?.name);
});
it.each(options)("sends the original $kind option id, then records it in the conversation", async (option) => {
it.each(options)("sends the original $kind option id, then collapses the resolved card", async (option) => {
const pending: AgentEntry = { type: "permission", id: 12, title: `Run ${command}`, options, chosen: null };
backend.answers = [];
backend.entries.set("A", [pending]);
@@ -747,31 +779,11 @@ describe("a pending permission request above the composer", () => {
act(() =>
useAgents.setState({ entries: new Map(backend.entries).set("A", [{ ...pending, chosen: option.name }]) }),
);
expect(card).not.toBeInTheDocument();
const record = within(stream()).getByRole("region", { name: "codex wants to run a command" });
expect(record.querySelector("pre")).toBeNull();
expect(within(record).queryByRole("button")).toBeNull();
expect(record.textContent).toContain("python3 …");
expect(record.textContent).not.toContain("write_text");
await waitFor(() => expect(document.activeElement?.tagName).toBe("TEXTAREA"));
});
it("is answered with a number key when the focus isn't in a text field", async () => {
const pending: AgentEntry = { type: "permission", id: 12, title: `Run ${command}`, options, chosen: null };
backend.answers = [];
backend.entries.set("A", [pending]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
await screen.findByRole("region", { name: "codex wants to run a command" });
fireEvent.keyDown(document.querySelector("textarea")!, { key: "1" });
expect(backend.answers).toEqual([]);
const elsewhere = document.body.appendChild(document.createElement("div"));
fireEvent.keyDown(elsewhere, { key: "1" });
fireEvent.keyDown(document.body, { key: "2" });
elsewhere.remove();
expect(backend.answers).toEqual([]);
fireEvent.keyDown(stream(), { key: "3" });
await waitFor(() => expect(backend.answers).toEqual([["A", 12, "opaque-no"]]));
expect(card.querySelector("pre")).toBeNull();
expect(within(card).queryByRole("button")).toBeNull();
expect(card.textContent).toContain("python3 …");
expect(card.textContent).not.toContain("write_text");
expect(card).toHaveFocus();
});
it("is answered from its line too, and one without options can be cancelled", async () => {
+29 -144
View File
@@ -27,7 +27,6 @@ import {
HOME_KIND,
SESSION_KIND,
TOOL_WINDOW,
appendEntry,
attentionChange,
entriesKey,
isBusy,
@@ -108,12 +107,7 @@ function save(key: string, value: unknown) {
}
}
const fallbackDraft: Draft = {
provider: "claude",
config: {},
isolation: "local",
base: null,
};
const fallbackDraft: Draft = { provider: "claude", config: {}, isolation: "local", base: null };
/**
* The saved New Task settings. A worktree is used only when the user picked it: drafts
@@ -157,37 +151,20 @@ export function updateDraft(patch: Partial<Draft>) {
save(DRAFT_KEY, draft);
}
/** What Settings › Agents says new tasks of `provider` start with, by option id. */
export const providerDefaults = (provider: string | undefined): Record<string, string> =>
(provider && useProject.getState().settings?.agents?.defaults?.[provider]) || {};
/**
* 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[] {
/** Config for the selected model, with only supported draft choices applied. */
export function draftConfig(provider: AgentProvider | undefined, draft: Draft): AgentConfigOption[] {
const config = (provider?.config ?? [])
.filter((o) => o.category !== "thought_level")
.map((o) => {
const choice = draft.config[o.id] ?? defaults[o.id];
return {
...o,
current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current,
};
const choice = draft.config[o.id];
return { ...o, 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(
...(provider?.thoughtLevels?.[model] ?? []).map((o) => {
const choice = draft.thoughtLevels?.[model]?.[o.id] ?? defaults[o.id];
return {
...o,
current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current,
};
const choice = draft.thoughtLevels?.[model]?.[o.id];
return { ...o, current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current };
}),
);
}
@@ -203,10 +180,7 @@ export function setDraftOption(id: string, value: string) {
const model = config.find((o) => o.category === "model")?.current;
if (model && config.find((o) => o.id === id)?.category === "thought_level") {
updateDraft({
thoughtLevels: {
...draft.thoughtLevels,
[model]: { ...draft.thoughtLevels?.[model], [id]: value },
},
thoughtLevels: { ...draft.thoughtLevels, [model]: { ...draft.thoughtLevels?.[model], [id]: value } },
});
} else {
updateDraft({ config: { ...draft.config, [id]: value } });
@@ -217,10 +191,7 @@ export const composerDraft = (key: string) => useAgents.getState().composers.get
export function updateComposer(key: string, patch: Partial<ComposerDraft>) {
useAgents.setState((s) => ({
composers: new Map(s.composers).set(key, {
...(s.composers.get(key) ?? emptyDraft()),
...patch,
}),
composers: new Map(s.composers).set(key, { ...(s.composers.get(key) ?? emptyDraft()), ...patch }),
lastComposer: key,
}));
}
@@ -280,11 +251,7 @@ export async function connect(): Promise<void> {
: call(commands.agentProviders()),
]);
} catch (e) {
if (gen === generation)
useAgents.setState({
loaded: true,
error: e instanceof Error ? e.message : String(e),
});
if (gen === generation) useAgents.setState({ loaded: true, error: e instanceof Error ? e.message : String(e) });
return;
}
if (gen !== generation) return;
@@ -307,46 +274,8 @@ export async function connect(): Promise<void> {
}
}
type EntryEvent = Extract<AgentEvent, { type: "entry" | "append" }>;
/** Entry events that arrived while their conversation loaded, applied on top of the copy it loads. */
const arrivedWhileLoading = new Map<string, EntryEvent[]>();
/** Whether the message each task streams now is the agent's reply, known without its conversation. */
const streamingReply = new Map<string, { index: number; reply: boolean }>();
/** `entries` with the event applied; `null` when the copy can't take it. */
function applyEntryEvent(entries: AgentEntry[], e: EntryEvent): AgentEntry[] | null {
if (e.type === "append") return appendEntry(entries, e.index, e.from, e.text);
const have = entries[e.index];
// Sent before the loaded copy was read: that copy already holds more of the message.
if (
have &&
(have.type === "agent" || have.type === "thought") &&
e.entry.type === have.type &&
have.text.length > e.entry.text.length &&
have.text.startsWith(e.entry.text)
)
return entries;
return upsertEntry(entries, e.index, e.entry);
}
function onEntryEvent(s: AgentsState, e: EntryEvent) {
const key = entriesKey(e.task, e.subagent);
if (s.entryLoads.get(key)?.error === null) {
arrivedWhileLoading.set(key, [...(arrivedWhileLoading.get(key) ?? []), e]);
return;
}
const current = s.entries.get(key);
if (!current) return;
const next = applyEntryEvent(current, e);
if (!next) void loadEntries(e.task, e.subagent);
else if (next !== current) useAgents.setState({ entries: new Map(s.entries).set(key, next) });
}
export function disconnect() {
generation++;
arrivedWhileLoading.clear();
streamingReply.clear();
useAgents.setState({
loaded: false,
error: null,
@@ -385,21 +314,13 @@ function onEvent(e: AgentEvent) {
composers.delete(e.id);
const unread = new Set(s.unread);
unread.delete(e.id);
useAgents.setState({
tasks,
composers,
unread,
...(s.docked === e.id ? { docked: null } : {}),
});
useAgents.setState({ tasks, composers, unread, ...(s.docked === e.id ? { docked: null } : {}) });
void editorService().closeInput?.(sessionKey(e.id));
break;
}
case "truncated": {
const current = s.entries.get(e.task);
if (current)
useAgents.setState({
entries: new Map(s.entries).set(e.task, current.slice(0, e.length)),
});
if (current) useAgents.setState({ entries: new Map(s.entries).set(e.task, current.slice(0, e.length)) });
scheduleChanges(e.task);
break;
}
@@ -408,9 +329,7 @@ function onEvent(e: AgentEvent) {
break;
}
case "limits": {
useAgents.setState({
limits: new Map(s.limits).set(e.provider, e.limits),
});
useAgents.setState({ limits: new Map(s.limits).set(e.provider, e.limits) });
break;
}
case "entry": {
@@ -422,22 +341,17 @@ function onEvent(e: AgentEvent) {
) {
markUnread(e.task);
}
if (!e.subagent) streamingReply.set(e.task, { index: e.index, reply: e.entry.type === "agent" });
onEntryEvent(s, e);
const key = entriesKey(e.task, e.subagent);
const current = s.entries.get(key);
if (!current) return;
const next = upsertEntry(current, e.index, e.entry);
if (next) useAgents.setState({ entries: new Map(s.entries).set(key, next) });
else void loadEntries(e.task, e.subagent);
if (e.entry.type === "tool" && e.entry.status === "completed" && e.entry.diffs.length) {
scheduleChanges(e.task);
}
break;
}
case "append": {
if (!e.subagent && e.text.trim()) {
const shown = s.entries.get(entriesKey(e.task, null))?.[e.index];
const streaming = streamingReply.get(e.task);
if (shown ? shown.type === "agent" : streaming?.index === e.index && streaming.reply) markUnread(e.task);
}
onEntryEvent(s, e);
break;
}
}
}
@@ -458,12 +372,7 @@ function notifyChange(prev: AgentTask | undefined, task: AgentTask) {
key: `agent:${task.id}`,
group: "tools",
sticky: change !== "finished",
actions: [
{
label: change === "finished" ? "Review" : "Open",
run: () => openTask(task.id),
},
],
actions: [{ label: change === "finished" ? "Review" : "Open", run: () => openTask(task.id) }],
});
}
@@ -472,9 +381,7 @@ export async function loadEntries(task: string, subagent: string | null = null):
const key = entriesKey(task, subagent);
if (useAgents.getState().entryLoads.get(key)?.error === null) return;
const load = { error: null };
useAgents.setState((s) => ({
entryLoads: new Map(s.entryLoads).set(key, load),
}));
useAgents.setState((s) => ({ entryLoads: new Map(s.entryLoads).set(key, load) }));
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const entries = await Promise.race([
@@ -484,21 +391,15 @@ export async function loadEntries(task: string, subagent: string | null = null):
}),
]);
if (useAgents.getState().entryLoads.get(key) !== load) return;
let loaded = entries;
for (const event of arrivedWhileLoading.get(key) ?? []) loaded = applyEntryEvent(loaded, event) ?? loaded;
arrivedWhileLoading.delete(key);
useAgents.setState((s) => {
const entryLoads = new Map(s.entryLoads);
entryLoads.delete(key);
return { entries: new Map(s.entries).set(key, loaded), entryLoads };
return { entries: new Map(s.entries).set(key, entries), entryLoads };
});
} catch (e) {
if (useAgents.getState().entryLoads.get(key) !== load) return;
arrivedWhileLoading.delete(key);
const error = (e instanceof Error ? e.message : String(e)).trim() || tx("Unknown error");
useAgents.setState((s) => ({
entryLoads: new Map(s.entryLoads).set(key, { error }),
}));
useAgents.setState((s) => ({ entryLoads: new Map(s.entryLoads).set(key, { error }) }));
} finally {
clearTimeout(timer);
}
@@ -507,9 +408,7 @@ export async function loadEntries(task: string, subagent: string | null = null):
export async function refreshChanges(task: string): Promise<void> {
try {
const changes = await call(commands.agentChanges(task));
useAgents.setState((s) => ({
changes: new Map(s.changes).set(task, changes),
}));
useAgents.setState((s) => ({ changes: new Map(s.changes).set(task, changes) }));
} catch {
// The worktree is gone or not a repository: nothing to list.
useAgents.setState((s) => ({ changes: new Map(s.changes).set(task, []) }));
@@ -546,10 +445,7 @@ export function useAgentCommands(
task: string | null,
offered: readonly AgentCommand[],
): readonly AgentCommand[] {
const [found, setFound] = useState<{
for: unknown[];
list: readonly AgentCommand[];
} | null>(null);
const [found, setFound] = useState<{ for: unknown[]; list: readonly AgentCommand[] } | null>(null);
useEffect(() => {
if (!provider || !isTauri()) return;
let alive = true;
@@ -581,12 +477,7 @@ function openTaskInEditor(task: string) {
}
function showDockedTask(task: string) {
useAgents.setState({
docked: task,
lastTask: task,
homeOpen: false,
lastComposer: task,
});
useAgents.setState({ docked: task, lastTask: task, homeOpen: false, lastComposer: task });
toolWindowService().show(TOOL_WINDOW, true);
void editorService().closeInput?.(sessionKey(task));
}
@@ -650,10 +541,7 @@ export function openHome(prompt?: string) {
if (project?.workspace) {
const previous = useAgents.getState().draft.projectRoot;
const projectRoot = defaultTaskProject(project, editorService().activePath(), previous);
updateDraft({
projectRoot,
...(projectRoot !== previous ? { base: null } : {}),
});
updateDraft({ projectRoot, ...(projectRoot !== previous ? { base: null } : {}) });
}
if (prompt !== undefined) updateComposer(HOME, { text: prompt });
if (useAgents.getState().sessionLocation === "toolWindow") {
@@ -677,7 +565,7 @@ export async function startTask(
providers.find((p) => p.id === draft.provider),
draft,
);
const defaults = { ...bypassDefaults(options), ...providerDefaults(draft.provider) };
const defaults = bypassDefaults(options);
const config = options.flatMap((o) => {
const value =
o.category === "model" || o.category === "thought_level"
@@ -877,10 +765,7 @@ export function addReferences(refs: Array<{ path: string; rel: string; mention:
const current = composerDraft(key);
// A worktree task sees the same files under its own directory.
const root = task?.isolation === "worktree" ? task.cwd : null;
const files: FileRef[] = refs.map((r) => ({
path: root ? `${root}/${r.rel}` : r.path,
rel: r.rel,
}));
const files: FileRef[] = refs.map((r) => ({ path: root ? `${root}/${r.rel}` : r.path, rel: r.rel }));
const mentions = refs.map((r) => r.mention).join(" ");
const text =
current.text && !/\s$/.test(current.text) ? `${current.text} ${mentions} ` : `${current.text}${mentions} `;
+5 -14
View File
@@ -20,16 +20,6 @@ import {
useAgents,
} from "./store";
/** The Agents tool window's options menu: where conversations open. */
export function useAgentsHeaderMenu(): MenuItem[] {
const location = useAgents((s) => s.sessionLocation);
return [
// A radio pair: check marks only, no action icons beside them.
{ kind: "action", action: "agents.useToolWindow", icon: "", checked: location === "toolWindow" },
{ kind: "action", action: "agents.useEditor", icon: "", checked: location === "editor" },
];
}
const canArchive = (task: AgentTask) => !isBusy(task.status) && task.status !== "archived";
const canFork = (task: AgentTask) => canArchive(task) && !!task.capabilities?.fork;
@@ -162,13 +152,13 @@ export const taskActions: Action[] = [
},
{
id: "agents.forkTask",
title: "Fork Agent Task",
title: "Copy Agent Task to a New Task",
category: "Agents",
hidden: true,
run: ({ arg }) => {
const task = resolveTasks([arg])[0];
if (task && canFork(task))
return forkTask(task.id, null).catch((e: unknown) => showError(tx("Couldn't fork the task"), String(e)));
return forkTask(task.id, null).catch((e: unknown) => showError(tx("Couldn't copy the task"), String(e)));
},
},
{
@@ -210,14 +200,15 @@ export function taskMenu(tasks: AgentTask[]): MenuItem[] {
return [
...(!multiple
? ([
{ kind: "action", action: "agents.openTask", label: "Open", arg: task.id },
{ kind: "action", action: "agents.renameTask", label: "Rename…", icon: "edit", arg: task.id },
{ kind: "action", action: "terminal.openAt", label: "Terminal", icon: "terminal", arg: task.cwd },
{ kind: "action", action: "terminal.openAt", label: "Open in Terminal", icon: "terminal", arg: task.cwd },
...(task.capabilities?.fork && task.status !== "archived"
? ([
{
kind: "action",
action: "agents.forkTask",
label: "Fork",
label: "Copy to a New Task",
icon: "branch",
arg: task.id,
disabled: !canFork(task),
-72
View File
@@ -1,72 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentEntry } from "@/bindings";
import { currentTool, foldedTurns, summarizeWork, workParts } from "./timeline";
const user = (text: string, extra: Partial<Extract<AgentEntry, { type: "user" }>> = {}): AgentEntry => ({
type: "user",
text,
attachments: [],
sentMs: 1000,
doneMs: 5000,
...extra,
});
const agent = (text: string): AgentEntry => ({ type: "agent", text });
const tool = (kind: string, extra: Partial<Extract<AgentEntry, { type: "tool" }>> = {}): AgentEntry => ({
type: "tool",
id: kind,
title: kind,
kind,
status: "completed",
locations: [],
output: null,
diffs: [],
...extra,
});
describe("summarizeWork", () => {
it("counts tool calls by what they did and edits by file", () => {
const s = summarizeWork([
{ type: "thought", text: "hmm" },
tool("read"),
tool("read"),
tool("search"),
tool("edit", { diffs: [{ path: "/a.ts", added: 3, removed: 1 }] }),
tool("edit", { diffs: [{ path: "/a.ts", added: 2, removed: 0 }] }),
tool("edit", { locations: ["/b.ts"] }),
tool("execute"),
tool("mcp_custom"),
]);
expect(s).toMatchObject({ thoughts: 1, read: 2, search: 1, edited: 2, run: 1, other: 1, added: 5, removed: 1 });
expect(workParts(s)).toEqual([
{ text: "Edited {count} files", count: 2 },
{ text: "Ran 1 command", count: 1 },
{ text: "Read {count} files", count: 2 },
{ text: "Searched once", count: 1 },
{ text: "1 other tool call", count: 1 },
]);
});
it("finds the call in progress", () => {
expect(currentTool([tool("read", { status: "running" }), tool("search")])?.kind).toBe("read");
expect(currentTool([tool("read")])).toBeNull();
});
});
describe("foldedTurns", () => {
it("folds a finished turn's work before its reply", () => {
const entries = [user("q"), tool("read"), agent("mid"), tool("edit"), agent("done"), user("next"), agent("ok")];
expect([...foldedTurns(entries, 0, false).values()]).toEqual([{ user: 0, reply: 4, sentMs: 1000, doneMs: 5000 }]);
});
it("leaves the turn in progress, turns without work, and turns ending otherwise open", () => {
const working = [user("q"), tool("read"), agent("a")];
expect(foldedTurns(working, 0, true).size).toBe(0);
expect(foldedTurns(working, 0, false).size).toBe(1);
expect(foldedTurns([user("q"), agent("a")], 0, false).size).toBe(0);
expect(foldedTurns([user("q"), tool("read")], 0, false).size).toBe(0);
expect(foldedTurns([user("q"), tool("read"), agent("a"), { type: "error", message: "x" }], 0, false).size).toBe(0);
expect(foldedTurns([user("q"), { type: "error", message: "x" }, tool("read"), agent("a")], 0, false).size).toBe(0);
expect(foldedTurns([user("q"), tool("read"), user("more", { steered: true }), agent("a")], 0, false).size).toBe(0);
expect(foldedTurns([user("q"), tool("read"), agent("a")], 1, false).size).toBe(0);
});
});
-146
View File
@@ -1,146 +0,0 @@
/**
* What a run of the agent's work amounts to, and which finished turns fold away. Pure:
* the conversation renders the rows these describe.
*/
import type { AgentEntry } from "@/bindings";
type ToolEntry = Extract<AgentEntry, { type: "tool" }>;
/** Tool calls of a run counted by what they did; edits count the files they changed. */
export interface WorkSummary {
thoughts: number;
read: number;
search: number;
edited: number;
run: number;
fetch: number;
other: number;
added: number;
removed: number;
subagents: number;
}
export function summarizeWork(entries: Iterable<AgentEntry>): WorkSummary {
const s: WorkSummary = {
thoughts: 0,
read: 0,
search: 0,
edited: 0,
run: 0,
fetch: 0,
other: 0,
added: 0,
removed: 0,
subagents: 0,
};
const edited = new Set<string>();
for (const e of entries) {
if (e.type === "thought") s.thoughts++;
else if (e.type === "subagent") s.subagents++;
else if (e.type === "tool") countTool(s, e, edited);
}
s.edited = edited.size;
return s;
}
function countTool(s: WorkSummary, e: ToolEntry, edited: Set<string>) {
for (const d of e.diffs) {
edited.add(d.path);
s.added += d.added;
s.removed += d.removed;
}
switch (e.kind) {
case "read":
s.read++;
break;
case "search":
s.search++;
break;
case "execute":
s.run++;
break;
case "fetch":
s.fetch++;
break;
case "edit":
case "delete":
case "move":
if (!e.diffs.length) e.locations.forEach((l) => edited.add(l));
break;
case "think":
break;
default:
s.other++;
}
}
/**
* The parts of a summary line, most telling first: "Read 4 files", "Edited 2 files"… The
* caller translates each with its count.
*/
export function workParts(s: WorkSummary): Array<{ text: string; count: number }> {
const parts: Array<[string, string, number]> = [
["Edited 1 file", "Edited {count} files", s.edited],
["Ran 1 command", "Ran {count} commands", s.run],
["Read 1 file", "Read {count} files", s.read],
["Searched once", "Searched {count} times", s.search],
["Fetched 1 page", "Fetched {count} pages", s.fetch],
["1 subagent", "{count} subagents", s.subagents],
["1 other tool call", "{count} other tool calls", s.other],
];
return parts.filter(([, , n]) => n > 0).map(([one, many, count]) => ({ text: count === 1 ? one : many, count }));
}
/** The running tool call of a run, if any: what the agent is doing right now. */
export function currentTool(entries: readonly AgentEntry[]): ToolEntry | null {
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i]!;
if (e.type === "tool" && (e.status === "running" || e.status === "pending")) return e;
}
return null;
}
/** A finished turn: the message that asked it at `user`, its work in `[user + 1, reply)`. */
export interface FoldedTurn {
user: number;
/** Index of the reply that stays in view; the turn's work comes before it. */
reply: number;
sentMs: number | null;
doneMs: number | null;
}
/**
* The finished turns whose work before their last reply folds into one line. A turn
* folds when it has ended, ended with a reply, and did something before that reply (and
* neither the user sent anything into it nor it ran into an error meanwhile); the
* latest turn stays open while the agent works. Turns asked before `from` aren't shown
* and so don't fold.
*/
export function foldedTurns(entries: readonly AgentEntry[], from: number, busy: boolean): Map<number, FoldedTurn> {
const turns = new Map<number, FoldedTurn>();
const starts: number[] = [];
entries.forEach((e, i) => {
if (e.type === "user" && !e.steered && i >= from) starts.push(i);
});
starts.forEach((user, k) => {
const end = starts[k + 1] ?? entries.length;
const last = k === starts.length - 1;
if (last && busy) return;
const asked = entries[user] as Extract<AgentEntry, { type: "user" }>;
let reply = -1;
for (let i = end - 1; i > user; i--) {
const e = entries[i]!;
if (e.type === "agent" && e.text.trim()) {
reply = i;
break;
}
// Something after the reply still wants to be seen (an error, an open question).
if (e.type === "error" || (e.type === "permission" && e.chosen === null)) return;
}
if (reply < 0 || reply === user + 1) return;
// What the user sent into the turn, and an error it ran into, stay in view.
if (entries.slice(user + 1, reply).some((e) => e.type === "user" || e.type === "error")) return;
turns.set(user, { user, reply, sentMs: asked.sentMs ?? null, doneMs: asked.doneMs ?? null });
});
return turns;
}
-32
View File
@@ -16,7 +16,6 @@ import { TASK_DRAG } from "./model";
import {
connect,
disconnect,
loadEntries,
dockTask,
markVisible,
openHome,
@@ -31,7 +30,6 @@ const backend = vi.hoisted(() => ({
tabs: [] as OpenEditorTab[],
channel: null as { onmessage: (event: AgentEvent) => void } | null,
tasks: [] as AgentTask[],
entries: null as Promise<AgentEntry[]> | null,
notify: vi.fn(),
openInput: vi.fn(),
closeInput: vi.fn(),
@@ -54,7 +52,6 @@ vi.mock("@/platform/ipc", async (original) => ({
},
agentProviders: async () => ({ status: "ok", data: [] }),
agentChanges: async () => ({ status: "ok", data: [] }),
agentEntries: async () => ({ status: "ok", data: await backend.entries }),
},
}));
vi.mock("@/workbench/contributions", async (original) => ({
@@ -152,14 +149,6 @@ describe("agent unread activity", () => {
expect(backend.notify).not.toHaveBeenCalled();
});
it("tracks a reply that streams on as appends, but not a thought", () => {
reply("one", "");
emit({ type: "append", task: "one", subagent: null, index: 0, from: 0, text: "Done" });
emit({ type: "entry", task: "two", subagent: null, index: 0, entry: { type: "thought", text: "" } });
emit({ type: "append", task: "two", subagent: null, index: 0, from: 0, text: "Hmm" });
expect([...useAgents.getState().unread]).toEqual(["one"]);
});
it.each(["ready", "needsInput", "failed", "limited"] as const)("marks background %s transitions unread", (status) => {
emit({ type: "task", task: task("one", status) });
expect(useAgents.getState().unread.has("one")).toBe(true);
@@ -382,24 +371,3 @@ describe("docking a conversation in the tool window", () => {
expect(useAgents.getState().sessionLocation).toBe("toolWindow");
});
});
describe("streaming while the conversation loads", () => {
it("applies what arrived meanwhile on top of the loaded copy", async () => {
let resolve!: (entries: AgentEntry[]) => void;
backend.entries = new Promise((r) => (resolve = r));
const load = loadEntries("one");
// Sent before the copy was read, then after.
emit({ type: "entry", task: "one", subagent: null, index: 1, entry: { type: "agent", text: "He" } });
emit({ type: "append", task: "one", subagent: null, index: 1, from: 2, text: "llo" });
emit({ type: "append", task: "one", subagent: null, index: 1, from: 5, text: " there" });
const user: AgentEntry = { type: "user", text: "Hi", attachments: [] };
await act(async () => {
resolve([user, { type: "agent", text: "Hello" }]);
await load;
});
expect(useAgents.getState().entries.get("one")).toEqual([user, { type: "agent", text: "Hello there" }]);
emit({ type: "append", task: "one", subagent: null, index: 1, from: 11, text: "!" });
expect(useAgents.getState().entries.get("one")?.[1]).toEqual({ type: "agent", text: "Hello there!" });
expect(useAgents.getState().entryLoads.size).toBe(0);
});
});
-24
View File
@@ -324,30 +324,6 @@ describe("build feature", () => {
});
});
it("scopes Build Artifacts to the active file's module, or the whole project without one", async () => {
provideEditorService({
openFile,
saveAll: () => Promise.resolve(),
activePath: () => "/p/core/src/A.java",
} as unknown as EditorService);
await executeAction("build.buildArtifacts", { activate: false });
expect(fake.calls.at(-1)).toMatchObject({ kind: "artifacts", file: "/p/core/src/A.java" });
await act(async () => {
fake.resolve(result([], true));
});
provideEditorService({
openFile,
saveAll: () => Promise.resolve(),
activePath: () => null,
} as unknown as EditorService);
await executeAction("build.buildArtifacts", { activate: false });
expect(fake.calls.at(-1)).toMatchObject({ kind: "artifacts", file: null });
await act(async () => {
fake.resolve(result([], true));
});
});
it("starts a build without a workbench", async () => {
show.mockImplementation(() => {
throw new Error("No workbench");
+3 -3
View File
@@ -102,14 +102,14 @@ export function activate(): () => void {
{
id: "build.buildArtifacts",
title: "Build Artifacts",
description: "Packages the current module's artifacts, or the whole project when no file is active",
description: "Packages the project (mvn package, gradle assemble, cargo build --release, npm run build)",
category: "Build",
when: WHEN,
keywords: ["package", "assemble", "jar", "release", "module"],
keywords: ["package", "assemble", "jar", "release"],
run: ({ arg }) => {
const options = typeof arg === "object" && arg !== null ? arg : {};
const activate = !("activate" in options && options.activate === false);
const build = startBuild("artifacts", moduleTarget(arg), activate);
const build = startBuild("artifacts", null, activate);
if ("wait" in options && options.wait === true) return build.then(() => undefined);
},
},
-8
View File
@@ -212,14 +212,6 @@ describe("data source types", () => {
expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b)));
});
it("adds StarRocks on the MySQL driver with the 9030 preset (#2452)", () => {
expect(vendorPreset(vendorById("starrocks")!)).toEqual({ driver: "mysql", vendor: "starrocks", port: 9030 });
expect(sourceUrl({ driver: "mysql", vendor: "starrocks", host: "sr", port: 9030, database: "" })).toBe(
"jdbc:starrocks://sr:9030",
);
expect(parseSourceUrl("starrocks://[email protected]:9030/test")?.source.vendor).toBe("starrocks");
});
it("falls back to the driver for unknown or mismatched presets", () => {
expect(vendorOf({ driver: "mysql", vendor: "tidb" }).label).toBe("TiDB");
expect(vendorOf({ driver: "postgres", vendor: "tidb" }).id).toBe("postgres");
-10
View File
@@ -75,15 +75,6 @@ export const VENDORS: readonly DbVendor[] = [
scheme: "singlestore",
},
{ id: "sqlite", label: "SQLite", driver: "sqlite", port: null, icon: "db-sqlite", scheme: "sqlite" },
{
id: "starrocks",
label: "StarRocks",
driver: "mysql",
port: 9030,
// No dedicated glyph yet: StarRocks speaks the MySQL protocol, so reuse its icon.
icon: "db-mysql",
scheme: "starrocks",
},
{
id: "supabase",
label: "Supabase",
@@ -147,7 +138,6 @@ const SCHEMES: Record<string, string> = {
singlestore: "singlestore",
memsql: "singlestore",
oceanbase: "oceanbase",
starrocks: "starrocks",
oracle: "oracle",
oracledb: "oracle",
sqlserver: "sqlServer",
-1
View File
@@ -89,7 +89,6 @@ export const ICLS_COLORS: Record<string, string[]> = {
CARET_COLOR: ["--caret"],
CARET_ROW_COLOR: ["--bg-current-line"],
SELECTION_BACKGROUND: ["--bg-editor-selection"],
SELECTION_FOREGROUND: ["--fg-editor-selection"],
LINE_NUMBERS_COLOR: ["--fg-line-number"],
LINE_NUMBER_ON_CARET_ROW_COLOR: ["--fg-line-number-current"],
// The new UI paints the gutter with this, else with the editor's background; `GUTTER_BACKGROUND` is the classic UI's.
+1 -31
View File
@@ -8,38 +8,8 @@ describe("agent chat settings", () => {
showWorkTime: true,
showUsageLimits: true,
showCost: false,
foldTurns: true,
smoothText: true,
thinking: "auto",
toolCalls: "grouped",
expandToolOutput: false,
sendWith: "enter",
whileRunning: "queue",
defaults: {},
});
const stored = validateSettings({ agents: { showUsageLimits: false, showCost: true, showWorkTime: "no" } }).agents;
expect(stored).toMatchObject({
showContextUsage: true,
showWorkTime: true,
showUsageLimits: false,
showCost: true,
});
});
it("keeps valid chat choices and per-agent defaults", () => {
const stored = validateSettings({
agents: {
thinking: "collapsed",
toolCalls: "nope",
sendWith: "modEnter",
defaults: { claude: { model: "opus", effort: 3 }, codex: "x" },
},
}).agents;
expect(stored).toMatchObject({
thinking: "collapsed",
toolCalls: "grouped",
sendWith: "modEnter",
defaults: { claude: { model: "opus" }, codex: {} },
});
expect(stored).toEqual({ showContextUsage: true, showWorkTime: true, showUsageLimits: false, showCost: true });
});
});
@@ -121,11 +121,9 @@ describe("theme tokens", () => {
/**
* Tokens the palette themes (Solarized, One) take from their light / dark base
* on purpose: colours the user assigns (project and data source colours), the
* platform's caption close and macOS window buttons, the tint strength, and
* the selection foreground, whose base default preserves syntax colours.
* platform's caption close and macOS window buttons, and the tint strength.
*/
const INHERITED_FROM_BASE =
/^--(project-color-|db-color-|caption-close-|fg-caption-close-|tint-strength$|fg-editor-selection$)/;
const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|tint-strength$)/;
describe("palette themes", () => {
const ISLANDS = ["light", "dark", "dark-hc"];
@@ -68,7 +68,6 @@ const GROUP_DESCRIPTIONS: Record<string, string> = {
Editor: "Configure code editing: completion, folding, tabs, fonts, colors, code style and inspections.",
"Build, Execution, Deployment": "Configure build tools, run configurations, debugger and deployment.",
"Languages & Frameworks": "Configure language servers, interpreters, SDKs and framework support.",
Agents: "Install coding agents and configure how they launch, chat and connect to the IDE.",
Tools: "Configure external tools, terminal, database and other integrations.",
"Version Control": "Configure the settings related to version control used in your project",
};
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff. Show more