mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-10 02:31:27 -10:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e46d61dc0 | ||
|
|
dba85e8716 | ||
|
|
c25459317b | ||
|
|
0d8ad0370a | ||
|
|
3e3088c525 | ||
|
|
331987fc17 | ||
|
|
e23073c1d4 | ||
|
|
1699306eaa | ||
|
|
c01a538ea4 | ||
|
|
7cc0d5769d | ||
|
|
9828f18300 | ||
|
|
c580dd7c69 | ||
|
|
da3db5cbd6 | ||
|
|
d1bef52041 | ||
|
|
1d4c0e1cd4 |
No files matched your search
File renamed without changes.
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
+1
-1
@@ -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",
|
||||
"docs/plugins/themes.md",
|
||||
"plugins/themes.md",
|
||||
"pnpm-lock.yaml",
|
||||
"**/*.toml",
|
||||
"fixtures",
|
||||
|
||||
@@ -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`, `docs/plugins/themes.md`.
|
||||
Plugin docs: `plugins/README.md`, `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
@@ -4232,8 +4232,10 @@ name = "ide-build"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"chardetng",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"ide-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
|
||||
<img src=".github/assets/wechat-group.png" alt="WeChat group QR code" width="200">
|
||||
|
||||
### Contributors
|
||||
|
||||
|
||||
@@ -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).
|
||||
See [theme sources and mapping](docs/themes.md). MIT license:
|
||||
MIT license:
|
||||
|
||||
```text
|
||||
Copyright (c) 2011 Ethan Schoonover
|
||||
@@ -290,7 +290,11 @@ 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 are listed in [theme sources and mapping](docs/themes.md).
|
||||
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).
|
||||
Both syntax themes use the following MIT notice:
|
||||
|
||||
```text
|
||||
|
||||
@@ -345,6 +345,8 @@ 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.
|
||||
@@ -852,6 +854,7 @@ 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();
|
||||
@@ -877,6 +880,7 @@ 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();
|
||||
@@ -1129,6 +1133,7 @@ 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),
|
||||
@@ -1250,11 +1255,12 @@ 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)?.clone();
|
||||
Some(AgentEvent::Entry { task: self.id.clone(), subagent, index: i as u32, entry })
|
||||
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?;
|
||||
Some(entry_event(&self.id, &mut streamed, subagent, i, entry))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -2316,3 +2322,77 @@ 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,6 +518,15 @@ 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,
|
||||
|
||||
@@ -2532,3 +2532,39 @@ 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");
|
||||
}
|
||||
@@ -7,7 +7,9 @@ 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
|
||||
|
||||
@@ -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) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
|
||||
let Some(root) = xml::read_text(settings).as_deref().and_then(xml::parse) 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 = std::fs::read_to_string(settings).ok()?;
|
||||
let text = xml::read_text(settings)?;
|
||||
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(|| std::fs::read_to_string(path).ok().and_then(|t| parse_pom(&t)))
|
||||
.or_insert_with(|| xml::read_text(path).as_deref().and_then(parse_pom))
|
||||
.clone()
|
||||
}
|
||||
|
||||
@@ -736,7 +736,7 @@ pub(crate) fn launch_inputs(
|
||||
return None;
|
||||
}
|
||||
out.push(path.clone());
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
let text = xml::read_text(&path)?;
|
||||
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 Ok(next) = std::fs::read_to_string(&pp) else { return chain };
|
||||
let Some(next) = xml::read_text(&pp) else { return chain };
|
||||
pom = resolver.pom(&pp);
|
||||
dir = pp.parent().unwrap_or(Path::new("")).to_path_buf();
|
||||
chain.push((pp, next));
|
||||
@@ -2297,6 +2297,24 @@ 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,
|
||||
|
||||
@@ -3,6 +3,57 @@
|
||||
//! 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 ("e, 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,
|
||||
@@ -210,4 +261,19 @@ 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"));
|
||||
}
|
||||
}
|
||||
@@ -59,16 +59,16 @@ pub struct Settings {
|
||||
/// Build, Execution, Deployment › Run Targets.
|
||||
pub run_targets: Vec<RunTarget>,
|
||||
pub cmake: CMakeSettings,
|
||||
/// Tools › Agents › Chat.
|
||||
/// Settings › Agents.
|
||||
pub agents: AgentSettings,
|
||||
/// Tools › MCP Server (global only).
|
||||
/// Agents › 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>>,
|
||||
}
|
||||
|
||||
/// What the meters in an agent task's header show.
|
||||
/// Settings › Agents: what the conversation shows and how messages are sent.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AgentSettings {
|
||||
@@ -80,15 +80,125 @@ 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 }
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools › MCP Server: agents use the IDE's built-in tools.
|
||||
/// 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.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct McpServerSettings {
|
||||
@@ -1350,6 +1460,22 @@ 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();
|
||||
|
||||
@@ -594,6 +594,13 @@ 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));
|
||||
@@ -839,6 +846,8 @@ 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);
|
||||
}
|
||||
@@ -1465,6 +1474,15 @@ 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() };
|
||||
|
||||
@@ -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" => Some(DbDriver::Mysql),
|
||||
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" | "starrocks" => Some(DbDriver::Mysql),
|
||||
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
|
||||
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
|
||||
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
|
||||
@@ -559,6 +559,7 @@ 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: docs/plugins/themes.md.
|
||||
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: plugins/themes.md.
|
||||
[[contributes.themes]]
|
||||
path = "themes/__SLUG__.theme.json"
|
||||
editor-scheme = "themes/__SLUG__.scheme.json"
|
||||
|
||||
@@ -426,7 +426,8 @@ 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| {
|
||||
revisions.iter().map(|id| r.log_details_cancellable(id, &cancel)).collect()
|
||||
r.enable_log_index(&project.cache);
|
||||
r.log_details_batch_cancellable(&revisions, &cancel)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -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 › Tools › MCP Server",
|
||||
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Agents › MCP Server",
|
||||
project.display()
|
||||
)
|
||||
};
|
||||
|
||||
+371
-153
@@ -5,6 +5,7 @@ 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.
|
||||
@@ -276,44 +277,60 @@ 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 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 key = format!("{}:{refs:?}:{shallow_vec:?}", 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 commits =
|
||||
std::sync::Arc::new(crate::log_index::IndexedView::new(log_indexed(r, &all, cancel, &mut index)?));
|
||||
let rows =
|
||||
std::sync::Arc::new(crate::log_index::IndexedView::new(log_indexed_rows(r, &all, cancel, &mut index)?));
|
||||
if index.views.len() >= 4 {
|
||||
index.views.clear();
|
||||
}
|
||||
index.views.insert(key, commits.clone());
|
||||
commits
|
||||
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)))
|
||||
};
|
||||
let start = q
|
||||
.seek
|
||||
.as_ref()
|
||||
.and_then(|id| view.positions.get(id).copied())
|
||||
.and_then(|id| find_pos(id))
|
||||
.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| view.positions.get(id).copied()).map(|i| i + 1).unwrap_or(0);
|
||||
let retain = q.retain_until.as_ref().and_then(|id| find_pos(id)).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, c) in view.commits.iter().enumerate().skip(start).take(limit) {
|
||||
for (i, row) in view.rows.iter().enumerate().skip(start).take(limit) {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut c = c.clone();
|
||||
c.log_position = Some(i as u32);
|
||||
c.log_total = Some(view.commits.len() as u32);
|
||||
let c = materialize_row(
|
||||
&repo,
|
||||
&mut index,
|
||||
table.as_ref(),
|
||||
row,
|
||||
&shallow,
|
||||
&decorations,
|
||||
true,
|
||||
Some((i as u32, total)),
|
||||
)?;
|
||||
let stop = q.stop_at.as_ref() == Some(&c.id);
|
||||
result.push(c);
|
||||
if stop {
|
||||
@@ -328,12 +345,186 @@ 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());
|
||||
}
|
||||
@@ -410,29 +601,14 @@ fn log_indexed(
|
||||
};
|
||||
let authors: Vec<_> =
|
||||
q.author.iter().chain(&q.authors).map(|a| a.trim().to_lowercase()).filter(|a| !a.is_empty()).collect();
|
||||
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(),
|
||||
};
|
||||
update_head_reachable(&repo, index, cancel)?;
|
||||
let mut range_ids_set = range_ids.as_ref().map(|ids| ids.iter().copied().collect::<HashSet<_>>());
|
||||
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 = 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),
|
||||
)
|
||||
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))
|
||||
} 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.
|
||||
@@ -473,7 +649,7 @@ fn log_indexed(
|
||||
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)?;
|
||||
visibility.range_ids = Some(
|
||||
range_ids_set = Some(
|
||||
boundaries
|
||||
.stdout_str()
|
||||
.lines()
|
||||
@@ -499,60 +675,30 @@ fn log_indexed(
|
||||
};
|
||||
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, &visibility.index.nodes)?
|
||||
.into_iter()
|
||||
.map(Ok),
|
||||
)
|
||||
Box::new(sort_subset(&repo, ids, q.order, q.first_parent, &shallow, cancel, &index.nodes)?.into_iter().map(Ok))
|
||||
} else {
|
||||
walk
|
||||
};
|
||||
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 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 mut out = Vec::new();
|
||||
let mut skip = q.skip as usize;
|
||||
let limit = if q.limit == 0 { usize::MAX } else { q.limit as usize };
|
||||
@@ -561,36 +707,44 @@ fn log_indexed(
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let id = item?;
|
||||
if !visibility.shows(&repo, id)? {
|
||||
if !unfiltered && !visibility.shows(&repo, id)? {
|
||||
continue;
|
||||
}
|
||||
if skip > 0 {
|
||||
skip -= 1;
|
||||
continue;
|
||||
}
|
||||
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()) {
|
||||
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) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -612,6 +766,7 @@ 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());
|
||||
@@ -623,8 +778,7 @@ fn sort_subset(
|
||||
(commit.parent_ids().map(|p| p.detach()).collect(), commit.time().map(|t| t.seconds).unwrap_or(0))
|
||||
};
|
||||
times.push(time);
|
||||
let mut ordered = Vec::new();
|
||||
let mut pending: Vec<_> = all_parents
|
||||
let direct: Vec<_> = all_parents
|
||||
.into_iter()
|
||||
.take(if shallow.contains(id) {
|
||||
0
|
||||
@@ -634,28 +788,70 @@ fn sort_subset(
|
||||
usize::MAX
|
||||
})
|
||||
.collect();
|
||||
let mut visited = HashSet::new();
|
||||
while let Some(parent) = pending.pop() {
|
||||
if !visited.insert(parent) {
|
||||
continue;
|
||||
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);
|
||||
}
|
||||
}
|
||||
if let Some(position) = positions.get(&parent) {
|
||||
if !ordered.contains(position) {
|
||||
ordered.push(*position);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
} 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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
ordered.reverse();
|
||||
for p in &ordered {
|
||||
children[*p] += 1;
|
||||
for &p in &ordered {
|
||||
children[p] += 1;
|
||||
}
|
||||
parents.push(ordered);
|
||||
}
|
||||
@@ -749,10 +945,22 @@ 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());
|
||||
@@ -760,16 +968,23 @@ impl Visibility<'_> {
|
||||
if let Some(shown) = self.matches.get(&id) {
|
||||
return Ok(*shown);
|
||||
}
|
||||
let shown = if !repo.has_object(id)
|
||||
let shown = if (!self.index.has_cached(&id) && !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(repo, id)?;
|
||||
let commit = self.index.get_with_table(repo, id, self.table)?;
|
||||
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)
|
||||
@@ -794,14 +1009,17 @@ impl Visibility<'_> {
|
||||
})
|
||||
})
|
||||
})
|
||||
&& (self.authors.is_empty()
|
||||
|| self.authors.iter().any(|a| {
|
||||
&& (self.authors.is_empty() || {
|
||||
let name = commit.author.to_lowercase();
|
||||
let email = commit.email.to_lowercase();
|
||||
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,
|
||||
@@ -823,22 +1041,22 @@ impl Visibility<'_> {
|
||||
}
|
||||
if self.shows(repo, id)? {
|
||||
self.projected.insert(id, vec![id]);
|
||||
} else if !repo.has_object(id) {
|
||||
} else if !self.index.has_cached(&id) && !repo.has_object(id) {
|
||||
self.projected.insert(id, Vec::new());
|
||||
} else {
|
||||
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<_>>>()?;
|
||||
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<_>>>()?
|
||||
};
|
||||
if !expanded {
|
||||
stack.push((id, true));
|
||||
stack.extend(ancestors.iter().rev().map(|p| (*p, false)));
|
||||
|
||||
+73
-46
@@ -902,59 +902,86 @@ 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 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,
|
||||
)?)
|
||||
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
|
||||
} 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());
|
||||
let message = index.message(&repo, commit_id)?;
|
||||
index.flush();
|
||||
message
|
||||
};
|
||||
let details = LogDetails {
|
||||
message,
|
||||
containing_branches: Vec::new(),
|
||||
signature: if !signed {
|
||||
"N".into()
|
||||
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 {
|
||||
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(),
|
||||
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
|
||||
};
|
||||
self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).insert(cache_key, details.clone());
|
||||
details
|
||||
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
|
||||
};
|
||||
details.containing_branches =
|
||||
branches.stdout_str().lines().filter(|s| !s.is_empty()).map(str::to_string).collect();
|
||||
Ok(details)
|
||||
for (details, branches) in out.iter_mut().zip(branches_per_commit) {
|
||||
details.containing_branches = branches;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Lists one immutable revision directory. Directory names end in `/`.
|
||||
|
||||
+331
-69
@@ -6,41 +6,136 @@ use crate::repo::{gx, to_string};
|
||||
use gix::ObjectId;
|
||||
use gix::bstr::ByteSlice;
|
||||
use ide_core::IdeResult;
|
||||
use redb::{Database, ReadableDatabase, TableDefinition};
|
||||
use redb::{Database, ReadOnlyTable, ReadableDatabase, TableDefinition};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
use std::time::SystemTime;
|
||||
|
||||
const COMMITS: TableDefinition<&str, &[u8]> = TableDefinition::new("summaries-v2");
|
||||
pub(crate) 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 commits: Vec<Commit>,
|
||||
pub positions: HashMap<String, usize>,
|
||||
pub rows: Vec<IndexedRow>,
|
||||
pub positions: HashMap<ObjectId, usize>,
|
||||
}
|
||||
impl IndexedView {
|
||||
pub fn new(commits: Vec<Commit>) -> Self {
|
||||
let positions = commits.iter().enumerate().map(|(i, c)| (c.id.clone(), i)).collect();
|
||||
Self { commits, positions }
|
||||
pub fn new(rows: Vec<IndexedRow>) -> Self {
|
||||
let positions = rows.iter().enumerate().map(|(i, r)| (r.id, i)).collect();
|
||||
Self { rows, 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, (Vec<ObjectId>, i64)>,
|
||||
pub nodes: HashMap<ObjectId, NodeEntry>,
|
||||
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(
|
||||
@@ -51,27 +146,7 @@ impl LogIndex {
|
||||
first_parent: bool,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<Vec<ObjectId>> {
|
||||
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));
|
||||
}
|
||||
self.ensure_nodes(repo, tips, shallow, cancel)?;
|
||||
let mut seen = HashSet::new();
|
||||
let mut ids = Vec::new();
|
||||
let mut pending = tips.to_vec();
|
||||
@@ -86,6 +161,101 @@ 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;
|
||||
@@ -104,16 +274,34 @@ 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 = 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 = 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 commit = if let Some(commit) = cached {
|
||||
Arc::new(commit)
|
||||
} else {
|
||||
@@ -148,12 +336,14 @@ 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() >= 4096 {
|
||||
self.flush();
|
||||
if self.memory.len() >= MAX_MEMORY_COMMITS {
|
||||
self.memory.clear();
|
||||
}
|
||||
self.memory.insert(id, commit.clone());
|
||||
@@ -161,7 +351,7 @@ impl LogIndex {
|
||||
}
|
||||
|
||||
pub fn message(&mut self, repo: &gix::Repository, id: ObjectId) -> IdeResult<String> {
|
||||
if let Some((_, message)) = self.dirty_messages.iter().find(|(key, _)| key == &id.to_string()) {
|
||||
if let Some(message) = self.messages.get(&id) {
|
||||
return Ok(message.clone());
|
||||
}
|
||||
let key = id.to_string();
|
||||
@@ -170,46 +360,54 @@ impl LogIndex {
|
||||
let table = txn.open_table(MESSAGES).ok()?;
|
||||
Some(table.get(key.as_str()).ok()??.value().to_string())
|
||||
});
|
||||
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()));
|
||||
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();
|
||||
}
|
||||
self.messages.insert(id, message.clone());
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
pub fn flush(&mut self) {
|
||||
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())?;
|
||||
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())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut table = txn.open_table(MESSAGES)?;
|
||||
for (id, message) in &self.dirty_messages {
|
||||
table.insert(id.as_str(), message.as_str())?;
|
||||
{
|
||||
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}");
|
||||
}
|
||||
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();
|
||||
}
|
||||
self.dirty_messages.clear();
|
||||
self.dirty.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,3 +424,67 @@ 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()]);
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,15 @@ 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();
|
||||
@@ -111,6 +120,15 @@ 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]
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,69 +0,0 @@
|
||||
# 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:[email protected]: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.
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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.
|
Before Width: | Height: | Size: 907 KiB |
-100
@@ -1,100 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,42 +0,0 @@
|
||||
# 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.
|
||||
+19
-15
@@ -61,7 +61,7 @@ const entries: AgentEntry[] = [
|
||||
|
||||
for (const theme of ["dark", "light"] as const) {
|
||||
for (const width of [760, 1400]) {
|
||||
test(`approval stays inside the agent column at ${width}px in ${theme}`, async ({ page }) => {
|
||||
test(`approval is asked above the composer at ${width}px in ${theme}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
@@ -85,43 +85,47 @@ 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) => {
|
||||
const transcript = el.parentElement!.parentElement!;
|
||||
// Asked above the composer, below the conversation.
|
||||
const area = el.closest('[class*="_composerArea_"]')!;
|
||||
const transcript = area.parentElement!.querySelector('[class*="_stream_"]')!;
|
||||
const prompt = transcript.querySelector('[class*="_user_"]')!;
|
||||
const reply = transcript.querySelector('[class*="_agentCard_"]')!;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const bounds = transcript.getBoundingClientRect();
|
||||
const bounds = area.getBoundingClientRect();
|
||||
return {
|
||||
scroll: transcript.scrollWidth,
|
||||
client: transcript.clientWidth,
|
||||
scroll: area.scrollWidth,
|
||||
client: area.clientWidth,
|
||||
inStream: transcript.contains(el),
|
||||
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,
|
||||
footerAligned:
|
||||
Math.abs(prompt.getBoundingClientRect().right - prompt.nextElementSibling!.getBoundingClientRect().right) <
|
||||
1,
|
||||
headAligned:
|
||||
Math.abs(
|
||||
prompt.getBoundingClientRect().right - prompt.previousElementSibling!.getBoundingClientRect().right,
|
||||
) < 1,
|
||||
cardWidth: rect.width,
|
||||
available: bounds.width,
|
||||
actionsVisible: getComputedStyle(reply.nextElementSibling!.querySelector('[class*="_messageActions_"]')!)
|
||||
.opacity,
|
||||
replyFooter: reply.nextElementSibling != null,
|
||||
};
|
||||
});
|
||||
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.footerAligned).toBe(true);
|
||||
// The last message stays actionable even while an approval follows it.
|
||||
expect(geometry.actionsVisible).toBe("1");
|
||||
expect(geometry.headAligned).toBe(true);
|
||||
// The turn waits on the approval: its interim message has no footer yet.
|
||||
expect(geometry.replyFooter).toBe(false);
|
||||
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"]) {
|
||||
|
||||
@@ -278,6 +278,8 @@ 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");
|
||||
|
||||
@@ -112,19 +112,23 @@ for (const selected of [false, true]) {
|
||||
});
|
||||
}
|
||||
|
||||
test("opens a folded process with the keyboard and jumps back to the latest reply", async ({ page }) => {
|
||||
test("opens a folded turn 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*='_processRun_']").first();
|
||||
const fold = stream.locator("[class*='_turnFold_']").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();
|
||||
|
||||
@@ -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']"],
|
||||
["tools.agents", "section[aria-label='Codex executable']"],
|
||||
["agents.list", "section[aria-label='Codex']"],
|
||||
]) {
|
||||
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 === "tools.agents") {
|
||||
if (id === "agents.list") {
|
||||
const path = "/src/features/agents/registry.ts";
|
||||
const { tauriCommands } = await import(/* @vite-ignore */ path);
|
||||
tauriCommands.providers = async () => [
|
||||
@@ -26,6 +26,9 @@ for (const [id, selector] of [
|
||||
installed: true,
|
||||
commandOverridden: false,
|
||||
env: {},
|
||||
config: [],
|
||||
commands: [],
|
||||
source: { kind: "builtin" },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+3
-3
@@ -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](../docs/plugins/indexing.md).
|
||||
[Background document indexers](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
|
||||
[`docs/plugins/themes.md`](../docs/plugins/themes.md).
|
||||
[`plugins/themes.md`](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 [`docs/plugins/themes.md`](../docs/plugins/themes.md)). The install
|
||||
from JetBrains IDE…_, see [`plugins/themes.md`](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>/`
|
||||
|
||||
File renamed without changes.
@@ -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
|
||||
[`docs/plugins/themes.md`](../../../docs/plugins/themes.md).
|
||||
[`plugins/themes.md`](../../themes.md).
|
||||
|
||||
```
|
||||
harbor-night/
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"--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: docs/plugins/themes.md.
|
||||
// Full reference: plugins/themes.md.
|
||||
{
|
||||
"id": "harbor-night",
|
||||
"name": "Harbor Night",
|
||||
|
||||
Generated
+1
-1
@@ -105,7 +105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin-openidea-mybatis"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openidea-plugin-openidea-mybatis"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
e79eafa3cef8de7dc8fb5821c3715ceb36c3d4bac92d3b5ca8aedbfd2ec189a1
|
||||
4ea84cbf4f9202712d8e147dd4631e9d56bf2e324f77de4c67239e9bf5f1f545
|
||||
@@ -1,10 +1,10 @@
|
||||
id = "openidea.mybatis"
|
||||
name = "MyBatis Navigator"
|
||||
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}."
|
||||
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}."
|
||||
authors = ["OpenIDEA contributors"]
|
||||
license = "Apache-2.0"
|
||||
change-notes = "0.1.0 — first release: navigation both ways, line markers, id and parameter completion."
|
||||
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."
|
||||
categories = ["frameworks"]
|
||||
engines.openidea = "^1.0"
|
||||
wasm = "dist/plugin.wasm"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! 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 methods and statement ids,
|
||||
//! completion of statement ids and `#{parameters}`.
|
||||
//! 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}`.
|
||||
|
||||
use openidea_plugin::providers::{CompletionItem, CompletionKind, Document, LineMarker, Providers};
|
||||
use openidea_plugin::text::{offset, position};
|
||||
@@ -24,9 +25,20 @@ pub struct Named {
|
||||
/// A mapper XML file.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct MapperXml {
|
||||
pub namespace: Option<String>,
|
||||
/// `<mapper namespace="…">` (the value's range).
|
||||
pub namespace: Option<Named>,
|
||||
/// `<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.
|
||||
@@ -34,6 +46,8 @@ 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>)>,
|
||||
}
|
||||
|
||||
@@ -55,12 +69,42 @@ 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 = attr(start, "namespace").map(|(v, ..)| v);
|
||||
} else if STATEMENTS.contains(&name.as_str())
|
||||
&& let Some((id, s, e)) = attr(start, "id")
|
||||
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.statements.push(Named { name: id, range: range_of(text, s, e) });
|
||||
out.include_refs.push(r);
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -225,11 +269,15 @@ 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 simple: String = text[i + 10..].trim_start().chars().take_while(|c| ident(*c)).collect();
|
||||
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 name = Some(match package {
|
||||
Some(p) => format!("{p}.{simple}"),
|
||||
None => simple,
|
||||
None => simple.clone(),
|
||||
});
|
||||
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;
|
||||
@@ -259,7 +307,7 @@ pub fn parse_java(text: &str) -> MapperJava {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
MapperJava { name, methods }
|
||||
MapperJava { name, decl, methods }
|
||||
}
|
||||
|
||||
fn project_of(path: &str) -> Option<Project> {
|
||||
@@ -290,7 +338,7 @@ impl Navigator {
|
||||
continue;
|
||||
}
|
||||
if let Some(ns) = parse_xml(&text).namespace {
|
||||
map.insert(ns, path);
|
||||
map.insert(ns.name, path);
|
||||
}
|
||||
}
|
||||
self.xml_index.insert(root.clone(), (Instant::now(), map));
|
||||
@@ -300,7 +348,7 @@ impl Navigator {
|
||||
&& let Ok(text) = project.read_file(&path)
|
||||
{
|
||||
let xml = parse_xml(&text);
|
||||
if xml.namespace.as_deref() == Some(namespace) {
|
||||
if xml.namespace.as_ref().is_some_and(|n| n.name == namespace) {
|
||||
return Some((path, xml));
|
||||
}
|
||||
}
|
||||
@@ -319,22 +367,67 @@ 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 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 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 (m, _) = java.methods.iter().find(|(m, _)| m.name == s.name)?;
|
||||
let tip = format!("{simple}.{}()", m.name);
|
||||
Some((s, Location { path: path.clone(), range: m.range }, tip))
|
||||
})
|
||||
.collect()
|
||||
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
|
||||
} else {
|
||||
let java = parse_java(&doc.text);
|
||||
let Some(name) = java.name else { return Vec::new() };
|
||||
@@ -380,7 +473,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() else { return Vec::new() };
|
||||
let Some(ns) = xml.namespace.clone().map(|n| n.name) 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);
|
||||
@@ -403,6 +496,24 @@ 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('}')
|
||||
@@ -458,7 +569,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_deref(), Some("com.acme.UserMapper"));
|
||||
assert_eq!(xml.namespace.as_ref().map(|n| n.name.as_str()), 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)));
|
||||
}
|
||||
@@ -572,7 +683,9 @@ 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");
|
||||
assert_eq!(n.line_markers(&xml)[0].tooltip, "Go to UserMapper.findById()");
|
||||
let markers = n.line_markers(&xml);
|
||||
assert_eq!(markers[0].tooltip, "Go to interface UserMapper");
|
||||
assert_eq!(markers[1].tooltip, "Go to UserMapper.findById()");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -586,6 +699,81 @@ 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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Theme plugins
|
||||
|
||||
OpenIDEA ships seven [built-in themes](../themes.md): Islands Light, Islands Dark,
|
||||
OpenIDEA ships seven built-in themes: 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`](../../plugins/official/harbor-night).
|
||||
[`plugins/official/harbor-night`](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`](../../plugins/README.md).
|
||||
manifest fields are described in [`plugins/README.md`](README.md).
|
||||
|
||||
## The theme file
|
||||
|
||||
@@ -55,7 +55,7 @@ manifest fields are described in [`plugins/README.md`](../../plugins/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`](../../s
|
||||
|---|---|
|
||||
| `--fg-editor`, `--bg-editor` | attribute `TEXT` (foreground, background) |
|
||||
| `--caret`, `--bg-current-line` | colour `CARET_COLOR`, `CARET_ROW_COLOR` |
|
||||
| `--bg-editor-selection` | colour `SELECTION_BACKGROUND` |
|
||||
| `--bg-editor-selection`, `--fg-editor-selection` | colour `SELECTION_BACKGROUND`, `SELECTION_FOREGROUND` |
|
||||
| `--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` | `editor.selectionBackground`, `editor.inactiveSelectionBackground` |
|
||||
| `--bg-editor-selection`, `--bg-editor-selection-inactive`, `--fg-editor-selection` | `editor.selectionBackground`, `editor.inactiveSelectionBackground`, `editor.selectionForeground` |
|
||||
| `--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 -->
|
||||
|
||||
255 themeable tokens: 125 in the UI theme file, 130 in the editor colour scheme file.
|
||||
256 themeable tokens: 125 in the UI theme file, 131 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -499,6 +499,7 @@ 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` |
|
||||
+6
-4
@@ -90,11 +90,13 @@
|
||||
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.
|
||||
// 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;
|
||||
if (
|
||||
!event.error &&
|
||||
(event.message === "ResizeObserver loop completed with undelivered notifications." ||
|
||||
event.message === "ResizeObserver loop limit exceeded")
|
||||
message === "ResizeObserver loop completed with undelivered notifications." ||
|
||||
message === "ResizeObserver loop limit exceeded"
|
||||
)
|
||||
return;
|
||||
show(event.error || `${event.message}\n${event.filename || ""}:${event.lineno || 0}:${event.colno || 0}`);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Regenerates the token reference in docs/plugins/themes.md from
|
||||
* Regenerates the token reference in 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 = "docs/plugins/themes.md";
|
||||
const DOC = "plugins/themes.md";
|
||||
const groups = themeTokenGroups(readFileSync("src/styles/tokens.css", "utf8"));
|
||||
const doc = readFileSync(DOC, "utf8");
|
||||
const next = withReference(doc, referenceMarkdown(groups));
|
||||
|
||||
@@ -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 docs/plugins/themes.md to fill them in.`,
|
||||
` → ${values.out}\nUnmapped tokens keep the built-in ${theme.kind} values; see plugins/themes.md to fill them in.`,
|
||||
);
|
||||
@@ -40,8 +40,8 @@ describe("theme token reference", () => {
|
||||
expect(() => themeTokenGroups(bad)).toThrow(/--b .*section comment/);
|
||||
});
|
||||
|
||||
it("is current in docs/plugins/themes.md (run pnpm gen:theme-docs)", () => {
|
||||
const doc = read("docs/plugins/themes.md");
|
||||
it("is current in plugins/themes.md (run pnpm gen:theme-docs)", () => {
|
||||
const doc = read("plugins/themes.md");
|
||||
expect(withReference(doc, referenceMarkdown(groups))).toBe(doc);
|
||||
});
|
||||
});
|
||||
@@ -159,6 +159,17 @@ 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.
|
||||
|
||||
@@ -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 docs/plugins/themes.md. */
|
||||
/** Markdown token reference (one table per group) for plugins/themes.md. */
|
||||
export function referenceMarkdown(groups: readonly TokenGroup[]): string {
|
||||
const { ui, scheme } = tokenSets(groups);
|
||||
const parts = [
|
||||
|
||||
@@ -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](../../../docs/plugins/indexing.md#find-usages-and-rename-api-13)
|
||||
See [the indexing contract](../../../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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Tools › MCP Server: runs `ide_mcp::McpServer` over built-in host tools
|
||||
//! Agents › 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 › Tools › MCP Server shows.
|
||||
/// What Settings › Agents › MCP Server shows.
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpServerStatus {
|
||||
|
||||
@@ -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,
|
||||
/// Tools › MCP Server: built-in IDE tools for agents.
|
||||
/// Agents › 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>,
|
||||
|
||||
+79
-10
@@ -1657,6 +1657,11 @@ 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. */
|
||||
@@ -1780,8 +1785,11 @@ export type AgentRetry = {
|
||||
error: string | null,
|
||||
};
|
||||
|
||||
/** What the meters in an agent task's header show. */
|
||||
export type AgentSettings = {
|
||||
/** 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 = {
|
||||
/** Context window use. */
|
||||
showContextUsage?: boolean,
|
||||
/** Time the agent spent on the task. */
|
||||
@@ -1790,6 +1798,50 @@ export type AgentSettings = {
|
||||
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. */
|
||||
@@ -5994,13 +6046,13 @@ export type MavenSettings = {
|
||||
activeProfiles?: string,
|
||||
};
|
||||
|
||||
/** Tools › MCP Server: agents use the IDE's built-in tools. */
|
||||
/** Agents › 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 › Tools › MCP Server shows. */
|
||||
/** What Settings › Agents › MCP Server shows. */
|
||||
export type McpServerStatus = {
|
||||
running: boolean,
|
||||
/** Why it isn't running though turned on. */
|
||||
@@ -7899,6 +7951,9 @@ 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;
|
||||
|
||||
@@ -8035,9 +8090,9 @@ export type Settings_Deserialize = {
|
||||
/** Build, Execution, Deployment › Run Targets. */
|
||||
runTargets?: RunTarget[],
|
||||
cmake?: CMakeSettings_Deserialize,
|
||||
/** Tools › Agents › Chat. */
|
||||
agents?: AgentSettings,
|
||||
/** Tools › MCP Server (global only). */
|
||||
/** Settings › Agents. */
|
||||
agents?: AgentSettings_Deserialize,
|
||||
/** Agents › MCP Server (global only). */
|
||||
mcpServer?: McpServerSettings,
|
||||
/** Plugin settings (`[contributes.settings]`): plugin id → its values by key. */
|
||||
plugins?: { [key in string]: { [key in string]: unknown } },
|
||||
@@ -8097,9 +8152,9 @@ export type Settings_Serialize = {
|
||||
/** Build, Execution, Deployment › Run Targets. */
|
||||
runTargets: RunTarget[],
|
||||
cmake: CMakeSettings_Serialize,
|
||||
/** Tools › Agents › Chat. */
|
||||
agents: AgentSettings,
|
||||
/** Tools › MCP Server (global only). */
|
||||
/** Settings › Agents. */
|
||||
agents: AgentSettings_Serialize,
|
||||
/** Agents › MCP Server (global only). */
|
||||
mcpServer: McpServerSettings,
|
||||
/** Plugin settings (`[contributes.settings]`): plugin id → its values by key. */
|
||||
plugins: { [key in string]: { [key in string]: unknown } },
|
||||
@@ -8673,6 +8728,11 @@ 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,
|
||||
@@ -8720,6 +8780,13 @@ 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`. */
|
||||
@@ -9066,6 +9133,8 @@ 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;
|
||||
|
||||
|
||||
@@ -70,8 +70,7 @@ import { copyReferenceToClipboard, declaredName, qualifiedReference, referenceOf
|
||||
import { declarationChain } from "./view/outline";
|
||||
import { recordClipboard } from "./clipboardHistory";
|
||||
import { useDocMeta, useOutline } from "./model/document";
|
||||
import { fontZoom, setFontZoom, stepFontZoom } from "./cm/prefs";
|
||||
import { useEditorRegistry } from "@/editor/api";
|
||||
import { resetAllEditorsFontZoom, stepAllEditorsFontZoom } from "./cm/prefs";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
@@ -121,7 +120,7 @@ function stepSurfaceFontSize(delta: 1 | -1): void {
|
||||
if (cmEl) {
|
||||
const cmView = EditorView.findFromDOM(cmEl);
|
||||
if (cmView) {
|
||||
stepFontZoom(cmView, delta);
|
||||
stepAllEditorsFontZoom(cmView, delta);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -129,7 +128,7 @@ function stepSurfaceFontSize(delta: 1 | -1): void {
|
||||
|
||||
const slot = activeSlot();
|
||||
if (!slot?.view) return;
|
||||
stepFontZoom(slot.view, delta);
|
||||
stepAllEditorsFontZoom(slot.view, delta);
|
||||
if (!slot.view.hasFocus && !getContext().popupOpen) slot.view.focus();
|
||||
}
|
||||
|
||||
@@ -898,8 +897,7 @@ export function registerEditorActions(): () => void {
|
||||
keywords: ["zoom", "font size", "mouse wheel"],
|
||||
when: "projectOpen",
|
||||
run: () => {
|
||||
for (const ed of useEditorRegistry.getState().editors)
|
||||
if (ed.view.state.field(fontZoom, false)) ed.view.dispatch({ effects: setFontZoom.of(0) });
|
||||
resetAllEditorsFontZoom();
|
||||
if (typeof document !== "undefined") {
|
||||
resetSurfaceFontZoom();
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ 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 {
|
||||
@@ -374,10 +375,16 @@ 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: () => 0,
|
||||
create: () => sharedFontZoom,
|
||||
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) =>
|
||||
@@ -400,11 +407,35 @@ export function stepFontZoom(view: EditorView, delta: number): void {
|
||||
if (next !== z) view.dispatch({ effects: setFontZoom.of(next) });
|
||||
}
|
||||
|
||||
const wheelZoom = EditorView.domEventHandlers({
|
||||
/**
|
||||
* 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({
|
||||
wheel(e, view) {
|
||||
if (!e.metaKey && !e.ctrlKey) return false;
|
||||
e.preventDefault();
|
||||
stepFontZoom(view, e.deltaY < 0 ? 1 : -1);
|
||||
stepAllEditorsFontZoom(view, e.deltaY < 0 ? 1 : -1);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -38,6 +38,8 @@ 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))",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
/**
|
||||
* 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,134 +5,3 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,54 +1,8 @@
|
||||
/**
|
||||
* 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";
|
||||
/** Settings › Agents › Coding Agents. */
|
||||
import { AgentList } from "./AgentList";
|
||||
import { tauriCommands, tauriRegistry, type CommandsBackend, type RegistryBackend } from "./registry";
|
||||
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,
|
||||
@@ -59,284 +13,9 @@ export function AgentRegistryPage({
|
||||
backend?: RegistryBackend;
|
||||
commandBackend?: CommandsBackend;
|
||||
}) {
|
||||
const [tab, setTab] = useState("builtin");
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
<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} />
|
||||
)}
|
||||
<AgentList backend={commandBackend} registry={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>
|
||||
);
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export function ReplyActions({
|
||||
{task && reply && operations.fork && (
|
||||
<IconButton
|
||||
icon="branch"
|
||||
title="Branch Conversation from Here"
|
||||
title="Fork"
|
||||
size="small"
|
||||
action="agents.forkReply"
|
||||
actionArg={{ task: task.id, turn: reply.turn }}
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
}
|
||||
.age {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
padding-right: 8px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
@@ -186,12 +187,3 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, fireEvent, render, renderHook, 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, AgentsLocationActions, AgentsView } from "./AgentsView";
|
||||
import { AgentsHeaderActions, AgentsView } from "./AgentsView";
|
||||
import { SESSION_KIND } from "./model";
|
||||
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, setSessionLocation, useAgents } from "./store";
|
||||
import { taskActions, taskMenu } from "./taskActions";
|
||||
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, useAgents } from "./store";
|
||||
import { taskActions, taskMenu, useAgentsHeaderMenu } from "./taskActions";
|
||||
import styles from "./Agents.module.css";
|
||||
|
||||
const editor = vi.hoisted(() => ({
|
||||
@@ -117,7 +117,6 @@ afterEach(() => {
|
||||
function setup() {
|
||||
render(
|
||||
<>
|
||||
<AgentsLocationActions />
|
||||
<AgentsHeaderActions />
|
||||
<AgentsView />
|
||||
<ContextMenuHost />
|
||||
@@ -166,7 +165,6 @@ 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" }));
|
||||
@@ -209,15 +207,13 @@ describe("Agents docked view", () => {
|
||||
expect(screen.getByTestId("docked-session")).toBeTruthy();
|
||||
});
|
||||
|
||||
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("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("opens tabs as usual without a docked conversation", () => {
|
||||
@@ -315,7 +311,9 @@ 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" }));
|
||||
expect(menu).toContainEqual(expect.objectContaining({ action: "agents.openTask" }));
|
||||
// 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" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,16 @@ import {
|
||||
showContextMenu,
|
||||
startPointerDrag,
|
||||
} from "@/ui";
|
||||
import { SESSION_KIND, TASK_DRAG, age, branchLabel, groupTasks, sessionKey, type TaskGroup } from "./model";
|
||||
import {
|
||||
SESSION_KIND,
|
||||
TASK_DRAG,
|
||||
age,
|
||||
branchLabel,
|
||||
groupTasks,
|
||||
providerIcon,
|
||||
sessionKey,
|
||||
type TaskGroup,
|
||||
} from "./model";
|
||||
import { openHome, useAgents } from "./store";
|
||||
import { taskMenu } from "./taskActions";
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
@@ -29,17 +38,6 @@ 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);
|
||||
@@ -59,7 +57,7 @@ const sessionPayloadTask = (payload: string): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
export function TaskStatusIcon({ status }: { status: Status }) {
|
||||
function TaskStatusIcon({ status }: { status: Status }) {
|
||||
switch (status) {
|
||||
case "needsInput":
|
||||
return <Icon name="warning" size={13} label="Needs input" />;
|
||||
@@ -195,10 +193,12 @@ export function AgentsView() {
|
||||
if (inline && !e.metaKey && !e.ctrlKey && !e.shiftKey) showTask(n.task.id);
|
||||
}}
|
||||
>
|
||||
<TaskStatusIcon status={n.task.status} />
|
||||
<Icon name={providerIcon(n.task.provider)} size={16} label={providerName(n.task.provider)} />
|
||||
<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,10 +207,7 @@ export function AgentsView() {
|
||||
{basename(n.task.projectRoot ?? project.root)}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.detail}>
|
||||
{providerName(n.task.provider)}
|
||||
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
|
||||
</span>
|
||||
{n.task.isolation === "worktree" && <span className={styles.detail}>{branchLabel(n.task.branch)}</span>}
|
||||
<span className={styles.age}>{age(n.task.updatedMs ?? 0)}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
/** 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));
|
||||
}
|
||||
@@ -99,15 +99,10 @@
|
||||
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;
|
||||
@@ -320,6 +315,10 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import type { AgentCommand, AgentConfigOption, AgentImage } from "@/bindings";
|
||||
import type { AgentCommand, AgentConfigOption, AgentImage, SendKey, WhileRunning } from "@/bindings";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { nativeDropPoint } from "@/platform/nativeDrop";
|
||||
import { invokeLocal } from "@/platform/tauriCore";
|
||||
@@ -57,6 +57,7 @@ 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";
|
||||
|
||||
@@ -84,7 +85,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, interrupting it). */
|
||||
/** `now`: send even while the agent works, into its turn (by default ⌘↵). */
|
||||
onSubmit: (now: boolean) => void;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
@@ -95,6 +96,29 @@ 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();
|
||||
@@ -112,6 +136,7 @@ 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();
|
||||
@@ -424,9 +449,10 @@ export function Composer(props: ComposerProps) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
const send = sendKeyAction(e, sendWith, whileRunning);
|
||||
if (send) {
|
||||
e.preventDefault();
|
||||
if (!props.disabled) props.onSubmit(e.metaKey || e.ctrlKey);
|
||||
if (!props.disabled) props.onSubmit(send === "now");
|
||||
recalled.current = { index: -1, stash: "" };
|
||||
return;
|
||||
}
|
||||
@@ -600,7 +626,7 @@ export function Composer(props: ComposerProps) {
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon="file-image"
|
||||
icon="image"
|
||||
title="Attach Image…"
|
||||
className={styles.composerButton}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
useAgentCommands,
|
||||
useAgents,
|
||||
} from "./store";
|
||||
import { useAgentSettings } from "./agentSettings";
|
||||
import styles from "./Home.module.css";
|
||||
|
||||
export function HomeEditor({ docked = false }: { docked?: boolean; input?: EditorInput }) {
|
||||
@@ -95,7 +96,8 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setStarting(false));
|
||||
};
|
||||
const config = draftConfig(provider, draft);
|
||||
const defaults = useAgentSettings().defaults;
|
||||
const config = draftConfig(provider, draft, (provider && defaults[provider.id]) || {});
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.home, docked && styles.docked)}>
|
||||
|
||||
@@ -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 } from "./Markdown";
|
||||
import { Markdown, markdownBlocks, markdownHtml } from "./Markdown";
|
||||
|
||||
const { openFile, openExternalUrl } = vi.hoisted(() => ({
|
||||
openFile: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -12,12 +12,19 @@ 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`;
|
||||
@@ -170,7 +177,11 @@ 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="" cwd="/repo" />);
|
||||
await waitFor(() => expect(screen.getByRole("img")).toHaveAttribute("alt", "Unable to load image: /tmp/missing.png"));
|
||||
expect(screen.getByRole("img")).not.toHaveAttribute("src");
|
||||
@@ -274,3 +285,43 @@ 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");
|
||||
});
|
||||
});
|
||||
@@ -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, useMemo, useRef, useSyncExternalStore } from "react";
|
||||
import { useLayoutEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { Marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { highlightInto } from "@/editor/lsp/markdown";
|
||||
@@ -60,6 +60,40 @@ 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) {
|
||||
@@ -184,6 +218,48 @@ 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,
|
||||
@@ -194,14 +270,32 @@ 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(() => (ref.current ? enhance(ref.current, cwd, images.current) : undefined), [html, cwd]);
|
||||
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);
|
||||
}, []);
|
||||
// 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.
|
||||
@@ -215,7 +309,7 @@ export function Markdown({
|
||||
live = false;
|
||||
pass.cancel();
|
||||
};
|
||||
}, [html, theme, onDiagramDrawn]);
|
||||
}, [text, theme, onDiagramDrawn]);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -244,7 +338,6 @@ export function Markdown({
|
||||
if (link) openPathLink(link);
|
||||
}
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -34,38 +34,11 @@
|
||||
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, just above the composer; the header keeps only the actions. */
|
||||
/* The task's state and its terminal and changes, just above the composer. */
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -75,6 +48,16 @@
|
||||
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;
|
||||
@@ -191,8 +174,7 @@
|
||||
width: fit-content;
|
||||
max-width: min(var(--session-card-width), 720px);
|
||||
}
|
||||
.userMessage > .messageHead,
|
||||
.userMessage > .messageFooter {
|
||||
.userMessage > .messageHead {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
/* The actions and time below a message, in flow so they never cover the next entry. */
|
||||
@@ -257,15 +239,18 @@
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.messageFooter > .messageTime {
|
||||
.messageFooter > .messageTime,
|
||||
.messageHead > .messageTime {
|
||||
opacity: 0;
|
||||
}
|
||||
.conversationRow:nth-last-child(1 of [data-message]) .messageFooter > .messageTime,
|
||||
.message:hover .messageFooter > .messageTime,
|
||||
/* 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,
|
||||
.agentMessage:hover .messageFooter > .messageTime,
|
||||
.message:focus-within .messageFooter > .messageTime,
|
||||
.message:focus-within .messageTime,
|
||||
.agentMessage:focus-within .messageFooter > .messageTime,
|
||||
.conversationRow:nth-last-child(1 of [data-message]) .messageActions,
|
||||
.message:hover .messageActions,
|
||||
.reply:hover .messageActions,
|
||||
.agentMessage:hover .messageActions,
|
||||
@@ -380,18 +365,23 @@
|
||||
.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 4px;
|
||||
padding: 0 2px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-elev);
|
||||
background: var(--md-code-bg);
|
||||
}
|
||||
.markdown :global(pre) {
|
||||
margin: 0 0 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-elev);
|
||||
background: var(--md-code-bg);
|
||||
overflow: auto;
|
||||
}
|
||||
.markdown :global(pre code) {
|
||||
@@ -449,6 +439,9 @@
|
||||
.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;
|
||||
@@ -1471,3 +1464,87 @@
|
||||
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;
|
||||
}
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
} from "@/ui";
|
||||
import { useTx, tx } from "@/platform/i18n";
|
||||
import { isImeKey } from "@/platform/ime";
|
||||
import { TaskStatusIcon } from "./AgentsView";
|
||||
import { Composer } from "./Composer";
|
||||
import { SubmitButton } from "./SubmitButton";
|
||||
import {
|
||||
@@ -72,6 +71,9 @@ import {
|
||||
import { ConfigPickers } from "./ConfigPickers";
|
||||
import { findRanges, formatDuration, formatSentAt } from "./conversation";
|
||||
import { ImagePreview, imageSrc } from "./ImagePreview";
|
||||
import { useAgentSettings } from "./agentSettings";
|
||||
import { currentTool, foldedTurns, summarizeWork, workParts, type FoldedTurn, type WorkSummary } from "./timeline";
|
||||
import { useRevealedText } from "./reveal";
|
||||
import { AgentReply, MessageActions, ReplyActions } from "./AgentReply";
|
||||
import {
|
||||
commandPrefix,
|
||||
@@ -187,13 +189,6 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
}}
|
||||
>
|
||||
<div className={styles.conversation} hidden={docked && changesOpen}>
|
||||
<Header
|
||||
task={task}
|
||||
changes={changes ?? null}
|
||||
changesOpen={changesOpen}
|
||||
onToggleChanges={() => setChangesOpen(!changesOpen)}
|
||||
docked={docked}
|
||||
/>
|
||||
{view && <SubagentBar task={task} path={path} cards={cards} onNavigate={setPath} />}
|
||||
<FoldedContent.Provider value={finding && shown}>
|
||||
<Stream
|
||||
@@ -215,7 +210,15 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
) : (
|
||||
<>
|
||||
<QuestionCard task={task} entries={entries ?? null} />
|
||||
<SessionComposer task={task} entries={entries ?? null} head={head} onOpenSubagent={openSubagent} />
|
||||
<SessionComposer
|
||||
task={task}
|
||||
entries={entries ?? null}
|
||||
head={head}
|
||||
changes={changes ?? null}
|
||||
changesOpen={changesOpen}
|
||||
onToggleChanges={() => setChangesOpen(!changesOpen)}
|
||||
onOpenSubagent={openSubagent}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -261,45 +264,28 @@ function useLocalHead(status: AgentTask["status"] | null, root: string | null):
|
||||
return local ? head : null;
|
||||
}
|
||||
|
||||
function Header({
|
||||
/** The task's terminal and changes, at the end of its status line. */
|
||||
function StatusActions({
|
||||
task,
|
||||
changes,
|
||||
changesOpen,
|
||||
onToggleChanges,
|
||||
docked,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
changes: AgentChange[] | null;
|
||||
changesOpen: boolean;
|
||||
onToggleChanges: () => void;
|
||||
/** The tool window bar above a docked conversation already shows the title. */
|
||||
docked?: boolean;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const count = changes?.length ?? 0;
|
||||
return (
|
||||
<div
|
||||
className={styles.header}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e, taskMenu([task]));
|
||||
}}
|
||||
>
|
||||
<TaskStatusIcon status={task.status} />
|
||||
{!docked && (
|
||||
<span className={styles.title} title={task.title}>
|
||||
{task.title}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.headerSpacer} />
|
||||
<span className={styles.statusActions}>
|
||||
<IconButton
|
||||
size="small"
|
||||
icon="terminal"
|
||||
title="Open in Terminal"
|
||||
title="Terminal"
|
||||
onClick={() => void executeAction("terminal.openAt", task.cwd)}
|
||||
/>
|
||||
{task.status === "archived" && (
|
||||
<IconButton icon="undo" title="Unarchive Task" action="agents.unarchiveTasks" actionArg={[task.id]} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.changesToggle}
|
||||
@@ -312,7 +298,7 @@ function Header({
|
||||
<Icon name="toolwindow-changes" size={14} />
|
||||
{count > 0 && <span>{count}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -321,21 +307,35 @@ function SessionStatus({
|
||||
task,
|
||||
entries,
|
||||
head,
|
||||
changes,
|
||||
changesOpen,
|
||||
onToggleChanges,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
head: string | null;
|
||||
changes: AgentChange[] | null;
|
||||
changesOpen: boolean;
|
||||
onToggleChanges: () => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const project = useProject((s) => s.project);
|
||||
const projectName = project?.workspace ? basename(task.projectRoot ?? project.root) : null;
|
||||
const where = task.isolation === "worktree" ? branchLabel(task.branch) : null;
|
||||
const pickedModel = task.status !== "archived" && task.config.some((o) => o.category === "model");
|
||||
return (
|
||||
<div className={styles.statusBar}>
|
||||
<div
|
||||
className={styles.statusBar}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e, taskMenu([task]));
|
||||
}}
|
||||
>
|
||||
<Icon name={providerIcon(task.provider)} size={13} className={styles.providerIcon} />
|
||||
<span className={styles.statusText} title={task.cwd}>
|
||||
{providerName(task.provider)}
|
||||
{task.model ? ` · ${task.model}` : ""}
|
||||
{/* The composer's model picker shows the model when the agent offers one. */}
|
||||
{task.model && !pickedModel ? ` · ${task.model}` : ""}
|
||||
{projectName ? ` · ${projectName}` : ""}
|
||||
{" · "}
|
||||
{where ?? (
|
||||
@@ -349,13 +349,14 @@ function SessionStatus({
|
||||
{task.status === "limited" && task.resetsMs != null && ` · ${tx`resets ${formatSentAt(task.resetsMs)}`}`}
|
||||
</span>
|
||||
<Meters task={task} entries={entries} />
|
||||
<StatusActions task={task} changes={changes} changesOpen={changesOpen} onToggleChanges={onToggleChanges} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Context use, usage limits and cost (when the agent reports them) and the time spent on
|
||||
* the task, each as Settings › Tools › Agents › Chat allows.
|
||||
* the task, each as Settings › Agents › Chat allows.
|
||||
*/
|
||||
function Meters({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) {
|
||||
const tx = useTx();
|
||||
@@ -674,8 +675,6 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
setAway(false);
|
||||
el.focus({ preventScroll: true });
|
||||
}, []);
|
||||
const waiting = entries?.some((e) => e.type === "permission" && !e.chosen);
|
||||
const lastUser = entries?.findLast((e): e is UserEntry => e.type === "user");
|
||||
// Forks and undo apply to the task's own turns, not a subagent's conversation.
|
||||
const turns = useMemo(() => (entries && !subagent ? turnNumbers(entries) : null), [entries, subagent]);
|
||||
const replies = useMemo(() => (entries && !subagent ? replyTurns(entries) : null), [entries, subagent]);
|
||||
@@ -683,11 +682,16 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
const busy = subagent ? subagentWorking(subagent.status) : isBusy(task.status);
|
||||
const lastIndex = entries ? entries.length - 1 : -1;
|
||||
// Keep the row stable when earlier history is prepended; its author can move with the work.
|
||||
const rows = useMemo(() => (entries ? conversationRows(entries, start) : []), [entries, start]);
|
||||
const { foldTurns, toolCalls } = useAgentSettings();
|
||||
const rows = useMemo(() => {
|
||||
if (!entries) return [];
|
||||
const rows = conversationRows(entries, start, toolCalls === "grouped");
|
||||
return foldTurns && !finding ? foldTurnRows(rows, foldedTurns(entries, start, busy)) : rows;
|
||||
}, [entries, start, busy, foldTurns, toolCalls, finding]);
|
||||
const authorHeaders = useMemo(() => {
|
||||
const headers = new Set<number>();
|
||||
const byAgent = (row: ConversationRow) =>
|
||||
row.kind === "process" ||
|
||||
row.kind !== "entry" ||
|
||||
(row.entry.type !== "user" &&
|
||||
row.entry.type !== "subagent" &&
|
||||
!(row.entry.type === "message" && row.entry.incoming));
|
||||
@@ -714,6 +718,20 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
),
|
||||
[task, turns, replies, times, lastIndex, busy, onOpenSubagent, onDiagramDrawn],
|
||||
);
|
||||
const renderConversationRow = (row: ConversationRow): ReactNode =>
|
||||
row.kind === "turn" ? (
|
||||
<TurnFold row={row} entries={entries ?? []}>
|
||||
{row.rows.map((inner) => (
|
||||
<div key={inner.index} className={styles.conversationRow}>
|
||||
{renderConversationRow(inner)}
|
||||
</div>
|
||||
))}
|
||||
</TurnFold>
|
||||
) : row.kind === "process" ? (
|
||||
<ProcessRun row={row} task={task} lastIndex={lastIndex} busy={busy} renderRow={renderRow} />
|
||||
) : (
|
||||
renderRow(row)
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.streamArea}
|
||||
@@ -750,7 +768,12 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
const text = selectionIn(e.currentTarget);
|
||||
const items = copyMenuFor(e.target) ?? [];
|
||||
if (text.trim() && task.status !== "archived") {
|
||||
items.push({ kind: "action", action: "agents.quoteReply", label: "Quote", arg: { task: task.id, text } });
|
||||
items.push({
|
||||
kind: "action",
|
||||
action: "agents.quoteReply",
|
||||
label: "Quote",
|
||||
arg: { task: task.id, text },
|
||||
});
|
||||
}
|
||||
if (!items.length) return;
|
||||
e.preventDefault();
|
||||
@@ -764,7 +787,10 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
actions={
|
||||
<Button
|
||||
onClick={() =>
|
||||
void executeAction("agents.retryConversation", { task: task.id, subagent: subagent?.id ?? null })
|
||||
void executeAction("agents.retryConversation", {
|
||||
task: task.id,
|
||||
subagent: subagent?.id ?? null,
|
||||
})
|
||||
}
|
||||
>
|
||||
Retry
|
||||
@@ -785,7 +811,7 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
data-message={
|
||||
(row.kind === "entry" && (row.entry.type === "user" || row.entry.type === "agent")) || undefined
|
||||
}
|
||||
data-process={row.kind === "process" || row.entry.type !== "user" || undefined}
|
||||
data-process={row.kind !== "entry" || row.entry.type !== "user" || undefined}
|
||||
>
|
||||
{authorHeaders.has(row.index) && (
|
||||
<div className={styles.agentAuthor}>
|
||||
@@ -795,11 +821,7 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{row.kind === "process" ? (
|
||||
<ProcessRun row={row} task={task} lastIndex={lastIndex} busy={busy} renderRow={renderRow} />
|
||||
) : (
|
||||
renderRow(row)
|
||||
)}
|
||||
{renderConversationRow(row)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -810,17 +832,6 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
{subagent.startedMs != null && <Elapsed since={subagent.startedMs} />}
|
||||
</div>
|
||||
)}
|
||||
{!subagent && (task.status === "running" || task.status === "starting") && !waiting && (
|
||||
<div className={styles.working}>
|
||||
<Spinner size={12} />
|
||||
{task.retry ? (
|
||||
<Retrying retry={task.retry} />
|
||||
) : (
|
||||
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
|
||||
)}
|
||||
{lastUser?.sentMs != null && !lastUser.doneMs && <Elapsed since={lastUser.sentMs} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{away && !!entries?.length && <JumpToLatest onJump={jumpToLatest} />}
|
||||
</div>
|
||||
@@ -972,7 +983,11 @@ function FindBar({
|
||||
};
|
||||
update();
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(stream, { subtree: true, childList: true, characterData: true });
|
||||
observer.observe(stream, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
@@ -1076,6 +1091,92 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** Memoized: the earlier entries rendered behind a reopened conversation leave the rest alone. */
|
||||
function AgentMessage({
|
||||
task,
|
||||
text,
|
||||
turnCount,
|
||||
reply,
|
||||
time,
|
||||
working,
|
||||
onDiagramDrawn,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
text: string;
|
||||
turnCount: number;
|
||||
reply?: ReplyTurn;
|
||||
time?: ReplyTimes | null;
|
||||
/** The reply streams: its text is revealed at a pace. */
|
||||
working: boolean;
|
||||
onDiagramDrawn: () => void;
|
||||
}) {
|
||||
const { smoothText } = useAgentSettings();
|
||||
const shown = useRevealedText(text, working && smoothText);
|
||||
// The revealed text grows the conversation between its renders: keep following the end.
|
||||
useLayoutEffect(() => {
|
||||
if (working) onDiagramDrawn();
|
||||
}, [shown, working, onDiagramDrawn]);
|
||||
return (
|
||||
<div className={styles.agentMessage}>
|
||||
<div className={styles.agentCard}>
|
||||
<AgentReply
|
||||
text={shown}
|
||||
cwd={task.cwd}
|
||||
task={task}
|
||||
reply={reply}
|
||||
turnCount={turnCount}
|
||||
onDiagramDrawn={onDiagramDrawn}
|
||||
actions={false}
|
||||
/>
|
||||
</div>
|
||||
{/* Only a turn's final reply carries its time and actions, once the turn has ended. */}
|
||||
{reply && !(isBusy(task.status) && reply.turn === turnCount - 1) && (
|
||||
<div className={styles.messageFooter}>
|
||||
<MessageTime sentMs={time?.sentMs ?? null} doneMs={time?.doneMs ?? null} at="done" />
|
||||
<ReplyActions text={text} task={task} reply={reply} turnCount={turnCount} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The agent's reasoning: open while it streams, folded after, as Settings › Agents › Chat says. */
|
||||
function ThoughtEntry({
|
||||
task,
|
||||
text,
|
||||
working,
|
||||
onDiagramDrawn,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
text: string;
|
||||
working: boolean;
|
||||
onDiagramDrawn: () => void;
|
||||
}) {
|
||||
const { thinking } = useAgentSettings();
|
||||
const open = thinking === "expanded" || (thinking === "auto" && working);
|
||||
return (
|
||||
<div className={styles.process}>
|
||||
<Fold
|
||||
className={clsx(styles.thought, thinking === "auto" && working && styles.thoughtLive)}
|
||||
open={open}
|
||||
summary={
|
||||
<summary>
|
||||
{working ? (
|
||||
<Spinner size={12} />
|
||||
) : (
|
||||
<Icon name="chevron-right" size={12} className={styles.thoughtChevron} />
|
||||
)}
|
||||
<T>{working ? "Thinking" : "Reasoning"}</T>
|
||||
</summary>
|
||||
}
|
||||
>
|
||||
<div className={styles.thoughtBody}>
|
||||
<Markdown text={text} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
</div>
|
||||
</Fold>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Entry = memo(function Entry({
|
||||
task,
|
||||
entry,
|
||||
@@ -1104,46 +1205,18 @@ const Entry = memo(function Entry({
|
||||
return <UserMessage task={task} entry={entry} turn={turn} turnCount={turnCount} />;
|
||||
case "agent":
|
||||
return (
|
||||
<div className={styles.agentMessage}>
|
||||
<div className={styles.agentCard}>
|
||||
<AgentReply
|
||||
text={entry.text}
|
||||
cwd={task.cwd}
|
||||
task={task}
|
||||
reply={reply}
|
||||
turnCount={turnCount}
|
||||
onDiagramDrawn={onDiagramDrawn}
|
||||
actions={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.messageFooter}>
|
||||
<MessageTime sentMs={time?.sentMs ?? null} doneMs={time?.doneMs ?? null} at="done" />
|
||||
<ReplyActions text={entry.text} task={task} reply={reply} turnCount={turnCount} />
|
||||
</div>
|
||||
</div>
|
||||
<AgentMessage
|
||||
task={task}
|
||||
text={entry.text}
|
||||
turnCount={turnCount}
|
||||
reply={reply}
|
||||
time={time}
|
||||
working={working}
|
||||
onDiagramDrawn={onDiagramDrawn}
|
||||
/>
|
||||
);
|
||||
case "thought":
|
||||
return (
|
||||
<div className={styles.process}>
|
||||
<Fold
|
||||
className={styles.thought}
|
||||
summary={
|
||||
<summary>
|
||||
{working ? (
|
||||
<Spinner size={12} />
|
||||
) : (
|
||||
<Icon name="chevron-right" size={12} className={styles.thoughtChevron} />
|
||||
)}
|
||||
<T>{working ? "Thinking" : "Reasoning"}</T>
|
||||
</summary>
|
||||
}
|
||||
>
|
||||
<div className={styles.thoughtBody}>
|
||||
<Markdown text={entry.text} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
</div>
|
||||
</Fold>
|
||||
</div>
|
||||
);
|
||||
return <ThoughtEntry task={task} text={entry.text} working={working} onDiagramDrawn={onDiagramDrawn} />;
|
||||
case "tool":
|
||||
return <ToolEntry task={task} entry={entry} />;
|
||||
case "plan":
|
||||
@@ -1191,7 +1264,8 @@ const Entry = memo(function Entry({
|
||||
</div>
|
||||
);
|
||||
case "permission":
|
||||
return <PermissionLine task={task} entry={entry} />;
|
||||
// Waiting for an answer, it is asked above the composer; the conversation keeps the record.
|
||||
return entry.chosen || entry.questions?.length ? <PermissionLine task={task} entry={entry} /> : null;
|
||||
case "error":
|
||||
return (
|
||||
<div className={styles.error} role="alert">
|
||||
@@ -1245,26 +1319,8 @@ function UserMessage({
|
||||
const files = entry.attachments.slice(0, entry.attachments.length - images.length);
|
||||
return (
|
||||
<div className={clsx(styles.message, styles.userMessage)}>
|
||||
{/* Actions and the time sit beside the author on hover: the reply under it has the turn's duration. */}
|
||||
<div className={styles.messageHead}>
|
||||
<span className={styles.role}>
|
||||
<T>You</T>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.user}>
|
||||
<MessageText text={entry.text} />
|
||||
<ImageStrip images={images} />
|
||||
{files.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
{files.map((a, i) => (
|
||||
<span key={i} className={styles.attachment}>
|
||||
<Icon name={fileIconName(a)} size={12} />
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.messageFooter}>
|
||||
<MessageActions>
|
||||
<IconButton size="small" icon="copy" title="Copy" onClick={() => void copyText(entry.text)} />
|
||||
{inPlace && entry.text && (
|
||||
@@ -1296,12 +1352,29 @@ function UserMessage({
|
||||
<IconButton
|
||||
size="small"
|
||||
icon="branch"
|
||||
title="Copy to a New Task From Here"
|
||||
onClick={() => void forkTask(task.id, turn + 1).catch(failed("Couldn't copy the task"))}
|
||||
title="Fork"
|
||||
onClick={() => void forkTask(task.id, turn + 1).catch(failed("Couldn't fork the task"))}
|
||||
/>
|
||||
)}
|
||||
</MessageActions>
|
||||
<MessageTime sentMs={entry.sentMs ?? null} doneMs={entry.doneMs ?? null} />
|
||||
<MessageTime sentMs={entry.sentMs ?? null} doneMs={null} />
|
||||
<span className={styles.role}>
|
||||
<T>You</T>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.user}>
|
||||
<MessageText text={entry.text} />
|
||||
<ImageStrip images={images} />
|
||||
{files.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
{files.map((a, i) => (
|
||||
<span key={i} className={styles.attachment}>
|
||||
<Icon name={fileIconName(a)} size={12} />
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1337,7 +1410,11 @@ function MessageEditor({
|
||||
await onSend(text.trim(), images);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
notifications().notify({ level: "error", title: "Couldn't send the message", body: String(e) });
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Couldn't send the message",
|
||||
body: String(e),
|
||||
});
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setSending(false);
|
||||
@@ -1437,7 +1514,9 @@ type IndexedEntry = { index: number; entry: AgentEntry };
|
||||
/** A row of the conversation: an entry, or a run of reasoning/tool calls folded together. */
|
||||
type ConversationRow =
|
||||
| { kind: "entry"; index: number; entry: AgentEntry }
|
||||
| { kind: "process"; index: number; entries: IndexedEntry[] };
|
||||
| { kind: "process"; index: number; entries: IndexedEntry[] }
|
||||
/** A finished turn's work before its reply, folded into one line under its message. */
|
||||
| { kind: "turn"; index: number; turn: FoldedTurn; rows: ConversationRow[] };
|
||||
|
||||
type EntryRow = Extract<ConversationRow, { kind: "entry" }>;
|
||||
|
||||
@@ -1451,7 +1530,8 @@ const isProcessEntry = (entry: AgentEntry) => entry.type === "thought" || (entry
|
||||
* The conversation's rows from `from`: a run of reasoning and finished tool calls becomes
|
||||
* one folded row (the work behind a decision), so messages and decisions stay readable.
|
||||
*/
|
||||
export function conversationRows(entries: readonly AgentEntry[], from: number): ConversationRow[] {
|
||||
export function conversationRows(entries: readonly AgentEntry[], from: number, group = true): ConversationRow[] {
|
||||
if (!group) return entries.slice(from).map((entry, i) => ({ kind: "entry", index: from + i, entry }));
|
||||
const rows: ConversationRow[] = [];
|
||||
let run: IndexedEntry[] = [];
|
||||
const flush = () => {
|
||||
@@ -1470,6 +1550,70 @@ export function conversationRows(entries: readonly AgentEntry[], from: number):
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** `rows` with the work of each turn in `turns` (keyed by its message) folded into one row. */
|
||||
export function foldTurnRows(rows: ConversationRow[], turns: ReadonlyMap<number, FoldedTurn>): ConversationRow[] {
|
||||
if (!turns.size) return rows;
|
||||
const out: ConversationRow[] = [];
|
||||
let open: Extract<ConversationRow, { kind: "turn" }> | null = null;
|
||||
for (const row of rows) {
|
||||
if (open && row.index < open.turn.reply) {
|
||||
open.rows.push(row);
|
||||
continue;
|
||||
}
|
||||
open = null;
|
||||
out.push(row);
|
||||
const turn = row.kind === "entry" ? turns.get(row.index) : undefined;
|
||||
if (turn) {
|
||||
open = { kind: "turn", index: row.index + 1, turn, rows: [] };
|
||||
out.push(open);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** "Reasoning · Edited 2 files · Read 4 files": what a run of work did. */
|
||||
function workSummaryText(work: WorkSummary, tx: ReturnType<typeof useTx>, toolCalls: number): string[] {
|
||||
const parts = workParts(work).map((p) => tx(p.text).replace("{count}", String(p.count)));
|
||||
if (!parts.length && toolCalls) parts.push(`${toolCalls} ${tx(toolCalls === 1 ? "tool call" : "tool calls")}`);
|
||||
if (work.thoughts) parts.unshift(tx("Reasoning"));
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** A finished turn's work, folded under its message: one line saying what it did and for how long. */
|
||||
function TurnFold({
|
||||
row,
|
||||
entries,
|
||||
children,
|
||||
}: {
|
||||
row: Extract<ConversationRow, { kind: "turn" }>;
|
||||
entries: readonly AgentEntry[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const { turn } = row;
|
||||
const work = summarizeWork(entries.slice(turn.user + 1, turn.reply));
|
||||
const parts = workSummaryText(work, tx, 0);
|
||||
const messages = entries.slice(turn.user + 1, turn.reply).filter((e) => e.type === "agent").length;
|
||||
if (messages) parts.push(tx(messages === 1 ? "1 message" : "{count} messages").replace("{count}", String(messages)));
|
||||
const took = turn.sentMs != null && turn.doneMs != null ? formatDuration(turn.doneMs - turn.sentMs) : null;
|
||||
return (
|
||||
<Fold
|
||||
className={clsx(styles.turnFold, styles.process)}
|
||||
summary={
|
||||
<summary className={styles.processSummary}>
|
||||
<Icon name="chevron-right" size={12} className={styles.processChevron} />
|
||||
<span>{parts.join(" · ")}</span>
|
||||
{work.added > 0 && <span className={styles.added}>+{work.added}</span>}
|
||||
{work.removed > 0 && <span className={styles.removed}>−{work.removed}</span>}
|
||||
{took && <span className={styles.muted}>{took}</span>}
|
||||
</summary>
|
||||
}
|
||||
>
|
||||
<div className={styles.turnBody}>{children}</div>
|
||||
</Fold>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reasoning and tool calls between messages, folded into one row. */
|
||||
const ProcessRun = memo(function ProcessRun({
|
||||
row,
|
||||
@@ -1489,14 +1633,10 @@ const ProcessRun = memo(function ProcessRun({
|
||||
const tools = row.entries.filter(
|
||||
(item): item is IndexedEntry & { entry: ToolEntryData } => item.entry.type === "tool",
|
||||
);
|
||||
const reasoning = row.entries.filter(({ entry }) => entry.type === "thought").length;
|
||||
const added = tools.reduce((n, { entry }) => n + entry.diffs.reduce((sum, d) => sum + d.added, 0), 0);
|
||||
const removed = tools.reduce((n, { entry }) => n + entry.diffs.reduce((sum, d) => sum + d.removed, 0), 0);
|
||||
const work = summarizeWork(row.entries.map(({ entry }) => entry));
|
||||
const { added, removed } = work;
|
||||
const working = busy && row.entries.some(({ index }) => index === lastIndex);
|
||||
const parts = [
|
||||
reasoning > 0 ? tx("Reasoning") : null,
|
||||
tools.length > 0 ? `${tools.length} ${tx(tools.length === 1 ? "tool call" : "tool calls")}` : null,
|
||||
].filter(Boolean);
|
||||
const parts = workSummaryText(work, tx, tools.length);
|
||||
return (
|
||||
<Fold
|
||||
className={clsx(styles.processRun, styles.process)}
|
||||
@@ -1558,6 +1698,7 @@ const ToolEntry = memo(function ToolEntry({
|
||||
task: AgentTask;
|
||||
entry: Extract<AgentEntry, { type: "tool" }>;
|
||||
}) {
|
||||
const { expandToolOutput } = useAgentSettings();
|
||||
const head = (
|
||||
<>
|
||||
<Icon name={TOOL_ICONS[entry.kind] ?? "run"} size={14} />
|
||||
@@ -1592,7 +1733,7 @@ const ToolEntry = memo(function ToolEntry({
|
||||
<div className={styles.process}>
|
||||
<Fold
|
||||
className={styles.toolDetails}
|
||||
open={liveTool(entry)}
|
||||
open={liveTool(entry) || expandToolOutput}
|
||||
summary={
|
||||
<summary className={styles.tool}>
|
||||
<Icon name="chevron-right" size={12} className={styles.toolChevron} />
|
||||
@@ -2163,17 +2304,168 @@ function QuestionForm({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What needs the user or tells where the agent is, above the composer: an approval it
|
||||
* waits for (answered with 1, 2, 3… when the focus isn't in a text field), the plan, the
|
||||
* task's changes, then what runs and what is queued, and a line saying what it does now.
|
||||
*/
|
||||
function Tray({
|
||||
task,
|
||||
entries,
|
||||
changes,
|
||||
changesOpen,
|
||||
onToggleChanges,
|
||||
children,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
changes: AgentChange[] | null;
|
||||
changesOpen: boolean;
|
||||
onToggleChanges: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const pending = entries?.find(
|
||||
(e): e is PermissionEntry => e.type === "permission" && !e.chosen && !e.questions?.length,
|
||||
);
|
||||
const plan = entries?.findLast((e): e is Extract<AgentEntry, { type: "plan" }> => e.type === "plan");
|
||||
const [planOpen, setPlanOpen] = useState(false);
|
||||
const done = plan?.items.filter((i) => i.status === "completed").length ?? 0;
|
||||
const planShown = plan && plan.items.length > 0 && done < plan.items.length;
|
||||
const added = changes?.reduce((n, c) => n + c.added, 0) ?? 0;
|
||||
const removed = changes?.reduce((n, c) => n + c.removed, 0) ?? 0;
|
||||
const busy = task.status === "running" || task.status === "starting";
|
||||
const tool = entries && busy ? currentTool(entries) : null;
|
||||
const lastUser = entries?.findLast((e): e is UserEntry => e.type === "user");
|
||||
return (
|
||||
<div className={styles.tray}>
|
||||
{pending && <PendingPermission task={task} entry={pending} />}
|
||||
{(planShown || !!changes?.length) && (
|
||||
<div className={styles.trayStrip}>
|
||||
{planShown && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trayChip}
|
||||
aria-expanded={planOpen}
|
||||
onClick={() => setPlanOpen(!planOpen)}
|
||||
>
|
||||
<Icon name="todo" size={13} />
|
||||
{tx`Plan ${done}/${plan.items.length}`}
|
||||
</button>
|
||||
)}
|
||||
{!!changes?.length && (
|
||||
<button type="button" className={styles.trayChip} aria-pressed={changesOpen} onClick={onToggleChanges}>
|
||||
<Icon name="edit" size={13} />
|
||||
{tx(changes.length === 1 ? "1 changed file" : "{count} changed files").replace(
|
||||
"{count}",
|
||||
String(changes.length),
|
||||
)}
|
||||
{added > 0 && <span className={styles.added}>+{added}</span>}
|
||||
{removed > 0 && <span className={styles.removed}>−{removed}</span>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{planShown && planOpen && (
|
||||
<div className={styles.trayPlan}>
|
||||
{plan.items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
styles.planItem,
|
||||
item.status === "completed" && styles.planDone,
|
||||
item.status === "in_progress" && styles.planCurrent,
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={item.status === "completed" ? "check" : item.status === "in_progress" ? "run" : "minus"}
|
||||
size={12}
|
||||
/>
|
||||
{item.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{busy && !pending && (
|
||||
<div className={styles.working} role="status">
|
||||
<Spinner size={12} />
|
||||
{task.retry ? (
|
||||
<Retrying retry={task.retry} />
|
||||
) : tool ? (
|
||||
<span className={styles.trayActivity} title={tool.title}>
|
||||
{shortTitle(task, tool.title || tool.kind)}
|
||||
</span>
|
||||
) : (
|
||||
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
|
||||
)}
|
||||
{lastUser?.sentMs != null && !lastUser.doneMs && <Elapsed since={lastUser.sentMs} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The approval the agent waits for, with its answers on 1, 2, 3… */
|
||||
function PendingPermission({ task, entry }: { task: AgentTask; entry: PermissionEntry }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const shown = useEditorShown();
|
||||
useEffect(() => {
|
||||
if (!shown) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const el = ref.current;
|
||||
if (!el || e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
// Only while this session holds the focus: keys typed elsewhere never answer for it.
|
||||
if (!target || !el.closest(`.${styles.session}`)?.contains(target)) return;
|
||||
if (target.closest("input, textarea, [contenteditable='true'], .cm-editor")) return;
|
||||
const n = Number(e.key);
|
||||
if (!Number.isInteger(n) || n < 1) return;
|
||||
const button = el.querySelectorAll<HTMLButtonElement>(`.${styles.permissionOption}`)[n - 1];
|
||||
if (!button || button.disabled) return;
|
||||
e.preventDefault();
|
||||
button.click();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [shown]);
|
||||
// Answered, the card goes: the focus it had moves on to the composer.
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
const area = el?.closest(`.${styles.composerArea}`);
|
||||
return () => {
|
||||
if (!el?.contains(document.activeElement) && document.activeElement !== document.body) return;
|
||||
requestAnimationFrame(() => {
|
||||
if (document.activeElement === document.body || !document.activeElement?.isConnected)
|
||||
area?.querySelector<HTMLElement>("textarea")?.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<div ref={ref} className={styles.trayAsk}>
|
||||
<PermissionLine task={task} entry={entry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionComposer({
|
||||
task,
|
||||
entries,
|
||||
head,
|
||||
changes,
|
||||
changesOpen,
|
||||
onToggleChanges,
|
||||
onOpenSubagent,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
head: string | null;
|
||||
changes: AgentChange[] | null;
|
||||
changesOpen: boolean;
|
||||
onToggleChanges: () => void;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
}) {
|
||||
const { sendWith, whileRunning } = useAgentSettings();
|
||||
const project = useProject((s) => s.project);
|
||||
const composer = useAgents((s) => s.composers.get(task.id)) ?? emptyDraft();
|
||||
const [sending, setSending] = useState(false);
|
||||
@@ -2184,22 +2476,44 @@ function SessionComposer({
|
||||
() => (entries ?? []).flatMap((e) => (e.type === "user" && e.text ? [e.text] : [])).reverse(),
|
||||
[entries],
|
||||
);
|
||||
// Only a running turn of an agent that takes messages mid-turn can be steered: otherwise
|
||||
// "now" would cancel the turn, so the message waits for it instead.
|
||||
const steers =
|
||||
whileRunning === "steer" &&
|
||||
!!task.capabilities?.steer &&
|
||||
task.status === "running" &&
|
||||
(composer.images.length === 0 || !!task.capabilities.images);
|
||||
const send = (now: boolean) => {
|
||||
if (!canSend(composer) || sending || task.status === "archived") return;
|
||||
setSending(true);
|
||||
sendMessage(task.id, now)
|
||||
sendMessage(task.id, now && (whileRunning !== "steer" || steers))
|
||||
.catch((e: unknown) =>
|
||||
notifications().notify({ level: "error", title: "Couldn't send the message", body: String(e) }),
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Couldn't send the message",
|
||||
body: String(e),
|
||||
}),
|
||||
)
|
||||
.finally(() => setSending(false));
|
||||
};
|
||||
const setOption = (id: string, value: string) =>
|
||||
void setTaskConfig(task.id, id, value).catch((e: unknown) =>
|
||||
notifications().notify({ level: "error", title: "Couldn't change the setting", body: String(e) }),
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Couldn't change the setting",
|
||||
body: String(e),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<div className={styles.composerArea}>
|
||||
<SessionStatus task={task} entries={entries} head={head} />
|
||||
<SessionStatus
|
||||
task={task}
|
||||
entries={entries}
|
||||
head={head}
|
||||
changes={changes}
|
||||
changesOpen={changesOpen}
|
||||
onToggleChanges={onToggleChanges}
|
||||
/>
|
||||
{task.status === "archived" && (
|
||||
<div className={styles.banner}>
|
||||
<span className={styles.bannerText}>
|
||||
@@ -2212,12 +2526,24 @@ function SessionComposer({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<WorkingSubagents entries={entries} onOpen={onOpenSubagent} />
|
||||
<BackgroundTasks task={task} />
|
||||
{task.queue.length > 0 && <Queue task={task} />}
|
||||
<Tray task={task} entries={entries} changes={changes} changesOpen={changesOpen} onToggleChanges={onToggleChanges}>
|
||||
<WorkingSubagents entries={entries} onOpen={onOpenSubagent} />
|
||||
<BackgroundTasks task={task} />
|
||||
{task.queue.length > 0 && <Queue task={task} />}
|
||||
</Tray>
|
||||
<Composer
|
||||
id={task.id}
|
||||
placeholder={busy ? "Queue a message for when the agent is done (⌘↵ sends it now)" : "Reply to the agent"}
|
||||
placeholder={
|
||||
!busy
|
||||
? "Reply to the agent"
|
||||
: steers
|
||||
? "Send into the agent's turn"
|
||||
: whileRunning === "steer"
|
||||
? "Queue a message for when the agent is done"
|
||||
: sendWith === "enter"
|
||||
? "Queue a message for when the agent is done (⌘↵ sends it now)"
|
||||
: "Queue a message for when the agent is done (⌘⇧↵ sends it now)"
|
||||
}
|
||||
root={task.cwd}
|
||||
projectRoot={project?.root ?? task.cwd}
|
||||
commands={agentCommands}
|
||||
@@ -2245,7 +2571,10 @@ function SessionComposer({
|
||||
}
|
||||
|
||||
/** Messages waiting for the turn to end: send now, edit, or drop. */
|
||||
const BACKGROUND_ICON: Record<string, IconName> = { local_bash: "terminal", monitor: "eye" };
|
||||
const BACKGROUND_ICON: Record<string, IconName> = {
|
||||
local_bash: "terminal",
|
||||
monitor: "eye",
|
||||
};
|
||||
|
||||
/** What the agent runs in the background; its subagents show as chips of their own. */
|
||||
function BackgroundTasks({ task }: { task: AgentTask }) {
|
||||
@@ -2253,7 +2582,11 @@ function BackgroundTasks({ task }: { task: AgentTask }) {
|
||||
const running = (task.background ?? []).filter((background) => background.kind !== "local_agent");
|
||||
if (!running.length) return null;
|
||||
const fail = (e: unknown) =>
|
||||
notifications().notify({ level: "error", title: "Couldn't stop the background task", body: String(e) });
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Couldn't stop the background task",
|
||||
body: String(e),
|
||||
});
|
||||
return (
|
||||
<div className={styles.background} aria-label={tx("Running in the background")}>
|
||||
{running.map((background) => (
|
||||
@@ -2279,7 +2612,11 @@ function BackgroundTasks({ task }: { task: AgentTask }) {
|
||||
|
||||
function Queue({ task }: { task: AgentTask }) {
|
||||
const fail = (e: unknown) =>
|
||||
notifications().notify({ level: "error", title: "Couldn't update the queue", body: String(e) });
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Couldn't update the queue",
|
||||
body: String(e),
|
||||
});
|
||||
return (
|
||||
<div className={styles.queue} aria-label={tx("Queued messages")}>
|
||||
{task.queue.map((q) => (
|
||||
@@ -2328,7 +2665,10 @@ function useChangesOpen(
|
||||
changes: AgentChange[] | null,
|
||||
docked: boolean,
|
||||
): [boolean, (open: boolean) => void] {
|
||||
const [state, setState] = useState<{ task: string; open: boolean | undefined }>(() => ({
|
||||
const [state, setState] = useState<{
|
||||
task: string;
|
||||
open: boolean | undefined;
|
||||
}>(() => ({
|
||||
task,
|
||||
open: docked ? false : loadChangesOpen()[task],
|
||||
}));
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
/** The tab strip's agent status indicator: spinner while the task is busy, dot once done (#2233). */
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
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;
|
||||
const busy = isBusy(status);
|
||||
// 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 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={status === "ready" ? "success" : status === "failed" || status === "limited" ? "danger" : "neutral"}
|
||||
>
|
||||
<Badge dot tone={tone!}>
|
||||
{label}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ function AgentRegistryPage(props: ComponentProps<typeof RegistryPage>) {
|
||||
}
|
||||
import { ADD_AGENT, providerOptions } from "./providerOptions";
|
||||
import {
|
||||
afterInstall,
|
||||
afterRemove,
|
||||
matchesQuery,
|
||||
rowActions,
|
||||
@@ -80,19 +81,30 @@ class FakeBackend implements RegistryBackend {
|
||||
this.installRequests.push(request);
|
||||
this.emit = onEvent;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.finish = resolve;
|
||||
this.finish = () => {
|
||||
this.registry = { ...this.registry, agents: afterInstall(this.registry.agents, id) };
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const row = (name: string) => screen.getByRole("listitem", { name });
|
||||
/** 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}`) });
|
||||
|
||||
describe("registry helpers", () => {
|
||||
it("matches every word of the query against name, id, description and authors", () => {
|
||||
@@ -176,17 +188,35 @@ describe("agent picker", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agents settings page", () => {
|
||||
const renderRegistry = (...args: Parameters<typeof render>) => {
|
||||
const result = render(...args);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "ACP Registry" }));
|
||||
return result;
|
||||
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 />
|
||||
</>,
|
||||
);
|
||||
};
|
||||
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(<AgentRegistryPage backend={backend} />);
|
||||
const copilot = await screen.findByRole("listitem", { name: "GitHub Copilot" });
|
||||
renderRegistry(backend);
|
||||
const copilot = await screen.findByRole("option", { name: "GitHub Copilot" });
|
||||
const expected = document.createElement("div");
|
||||
expected.innerHTML = iconMarkup("agent-copilot")!;
|
||||
expect(copilot.querySelector("svg")?.innerHTML).toBe(expected.querySelector("svg")?.innerHTML);
|
||||
@@ -195,18 +225,20 @@ describe("Agents settings page", () => {
|
||||
|
||||
it("lists registry agents, says they are third-party, and searches them", async () => {
|
||||
const backend = new FakeBackend();
|
||||
renderRegistry(<AgentRegistryPage backend={backend} />);
|
||||
renderRegistry(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(row("Cline")).getByText("3.0.74 available")).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")).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("listitem")).toHaveLength(1);
|
||||
expect(screen.getAllByRole("option")).toHaveLength(1);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search agents" }), { target: { value: "nothing" } });
|
||||
expect(screen.getByText("No agents match")).toBeInTheDocument();
|
||||
});
|
||||
@@ -214,7 +246,7 @@ describe("Agents settings page", () => {
|
||||
it("installs with progress, then reports the change", async () => {
|
||||
const backend = new FakeBackend();
|
||||
const onChanged = vi.fn();
|
||||
renderRegistry(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
|
||||
renderRegistry(backend, onChanged);
|
||||
await waitFor(() => expect(row("Amp")).toBeInTheDocument());
|
||||
fireEvent.click(within(row("Amp")).getByRole("button", { name: "Install" }));
|
||||
expect(backend.installs).toEqual(["amp-acp"]);
|
||||
@@ -226,21 +258,16 @@ describe("Agents settings page", () => {
|
||||
|
||||
await act(async () => backend.finish());
|
||||
expect(onChanged).toHaveBeenCalledTimes(1);
|
||||
expect(within(row("Amp")).getByText("Installed")).toBeInTheDocument();
|
||||
expect(within(row("Amp")).getByRole("button", { name: "Remove" })).toBeEnabled();
|
||||
await waitFor(() => expect(within(row("Amp")).getByRole("button", { name: "Remove" })).toBeEnabled());
|
||||
expect(within(row("Amp")).getByText("ACP Registry · 0.10.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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(
|
||||
<>
|
||||
<AgentRegistryPage backend={backend} />
|
||||
<MessagesHost />
|
||||
</>,
|
||||
);
|
||||
renderRegistry(backend);
|
||||
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");
|
||||
@@ -257,12 +284,7 @@ describe("Agents settings page", () => {
|
||||
|
||||
it("installs an agent with a checksum without asking", async () => {
|
||||
const backend = new FakeBackend();
|
||||
renderRegistry(
|
||||
<>
|
||||
<AgentRegistryPage backend={backend} />
|
||||
<MessagesHost />
|
||||
</>,
|
||||
);
|
||||
renderRegistry(backend);
|
||||
await waitFor(() => expect(row("Amp")).toBeInTheDocument());
|
||||
expect(screen.queryByText("No checksum provided")).toBeNull();
|
||||
fireEvent.click(within(row("Amp")).getByRole("button", { name: "Install" }));
|
||||
@@ -272,7 +294,7 @@ describe("Agents settings page", () => {
|
||||
|
||||
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(<AgentRegistryPage backend={backend} />);
|
||||
const { unmount } = renderRegistry(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" })));
|
||||
@@ -296,7 +318,7 @@ describe("Agents settings page", () => {
|
||||
const backend = new FakeBackend();
|
||||
const onChanged = vi.fn();
|
||||
backend.registry = { agents: [{ ...AMP, installing: true }, CLINE], warning: null };
|
||||
renderRegistry(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
|
||||
renderRegistry(backend, onChanged);
|
||||
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.
|
||||
@@ -306,7 +328,9 @@ describe("Agents settings page", () => {
|
||||
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")).getByText("Installed")).toBeInTheDocument(), { timeout: 4000 });
|
||||
await waitFor(() => expect(within(row("Amp")).getByRole("button", { name: "Remove" })).toBeInTheDocument(), {
|
||||
timeout: 4000,
|
||||
});
|
||||
expect(onChanged).toHaveBeenCalledTimes(1);
|
||||
expect(within(row("Cline")).getByRole("button", { name: "Update" })).toBeEnabled();
|
||||
});
|
||||
@@ -314,9 +338,11 @@ describe("Agents settings page", () => {
|
||||
it("removes an installed agent", async () => {
|
||||
const backend = new FakeBackend();
|
||||
const onChanged = vi.fn();
|
||||
renderRegistry(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
|
||||
renderRegistry(backend, onChanged);
|
||||
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);
|
||||
@@ -325,12 +351,12 @@ describe("Agents settings page", () => {
|
||||
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(<AgentRegistryPage backend={backend} />);
|
||||
const { unmount } = renderRegistry(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(<AgentRegistryPage backend={backend} />);
|
||||
renderRegistry(backend);
|
||||
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Cannot load the ACP Registry: offline"));
|
||||
});
|
||||
});
|
||||
@@ -397,12 +423,12 @@ describe("Built-in executable overrides", () => {
|
||||
component: () => <RegistryPage backend={backend} commandBackend={commands} onChanged={onChanged} />,
|
||||
});
|
||||
render(<SettingsDialog />);
|
||||
return screen.findByRole("region", { name: "Claude Code executable" });
|
||||
return screen.findByRole("region", { name: "Claude Code" });
|
||||
};
|
||||
const apply = () => screen.getByRole("button", { name: "Apply" });
|
||||
const field = () => screen.getByRole("textbox", { name: "Executable" });
|
||||
|
||||
it("follows locale changes in tabs and renders login commands as inline code", async () => {
|
||||
it("follows locale changes and renders login commands as inline code", async () => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [
|
||||
claudeProvider("claude"),
|
||||
@@ -424,14 +450,10 @@ 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");
|
||||
@@ -653,3 +675,111 @@ 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/** 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]);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "./composerModel";
|
||||
import {
|
||||
age,
|
||||
appendEntry,
|
||||
attentionChange,
|
||||
branchLabel,
|
||||
entriesKey,
|
||||
@@ -92,12 +93,30 @@ 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 },
|
||||
@@ -153,7 +172,11 @@ 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/" };
|
||||
@@ -182,7 +205,10 @@ 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",
|
||||
@@ -203,7 +229,9 @@ 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",
|
||||
@@ -224,14 +252,30 @@ 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,
|
||||
@@ -282,6 +326,22 @@ 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");
|
||||
@@ -365,9 +425,17 @@ 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -385,3 +453,30 @@ 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,4 +1,5 @@
|
||||
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";
|
||||
@@ -283,3 +284,23 @@ 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();
|
||||
});
|
||||
});
|
||||
@@ -19,21 +19,22 @@ import {
|
||||
toolWindowService,
|
||||
} from "@/workbench/contributions";
|
||||
import { AgentsStatus } from "./AgentsStatus";
|
||||
import { HOME_KIND, REGISTRY_PAGE, SESSION_KIND, TASK_DRAG, TOOL_WINDOW } from "./model";
|
||||
import { AGENTS_GROUP, HOME_KIND, REGISTRY_PAGE, SESSION_KIND, TASK_DRAG, TOOL_WINDOW, providerIcon } from "./model";
|
||||
import { sendPaths, sendSelection } from "./references";
|
||||
import { focusedSession } from "./sessionFocus";
|
||||
import { connect, disconnect, openHome, useAgents } from "./store";
|
||||
import { TaskStatusMark } from "./TaskStatusMark";
|
||||
import { taskActions } from "./taskActions";
|
||||
import { taskActions, useAgentsHeaderMenu } from "./taskActions";
|
||||
import { replyActions } from "./replyActions";
|
||||
|
||||
/** Settings › Tools › Agents (also used by the Settings window). */
|
||||
/** Settings › Agents › Coding Agents (also used by the Settings window). */
|
||||
export function activateSettingsPages(): () => void {
|
||||
return registerSettingsPage({
|
||||
id: REGISTRY_PAGE,
|
||||
title: "Agents",
|
||||
group: "Tools",
|
||||
order: 500,
|
||||
title: "Coding Agents",
|
||||
group: AGENTS_GROUP,
|
||||
// First in Agents, which sits after Languages & Frameworks and before Tools.
|
||||
order: 450,
|
||||
keywords: ["agent", "acp", "registry", "install", "update", "coding agent"],
|
||||
component: lazyComponent(() => import("./AgentRegistryPage").then((m) => m.AgentRegistryPage)),
|
||||
});
|
||||
@@ -73,7 +74,8 @@ export function activate(): () => void {
|
||||
anchor: "right",
|
||||
order: 0.5,
|
||||
component: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsView)),
|
||||
headerContent: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsLocationActions)),
|
||||
headerMenu: useAgentsHeaderMenu,
|
||||
plainTitle: true,
|
||||
headerActions: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsHeaderActions)),
|
||||
useBadge: () => {
|
||||
const n = useAgents(
|
||||
@@ -89,7 +91,10 @@ 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: () => "toolwindow-agents",
|
||||
icon: (input) => {
|
||||
const task = useAgents.getState().tasks.get((input.data as { task: string }).task);
|
||||
return task ? providerIcon(task.provider) : "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`).
|
||||
@@ -101,7 +106,10 @@ export function activate(): () => void {
|
||||
kind: HOME_KIND,
|
||||
component: lazyComponent(() => import("./HomeEditor").then((m) => m.HomeEditor)),
|
||||
title: () => "New Agent Task",
|
||||
icon: () => "toolwindow-agents",
|
||||
icon: (input) => {
|
||||
const task = useAgents.getState().tasks.get((input.data as { task: string }).task);
|
||||
return task ? providerIcon(task.provider) : "toolwindow-agents";
|
||||
},
|
||||
restorable: true,
|
||||
}),
|
||||
// An explicit editor drop sets the placement for subsequent sessions too.
|
||||
|
||||
@@ -7,8 +7,10 @@ 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 › Tools › Agents: the ACP Registry. */
|
||||
export const REGISTRY_PAGE = "tools.agents";
|
||||
/** 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";
|
||||
|
||||
export const sessionKey = (task: string) => `agent:${task}`;
|
||||
|
||||
@@ -95,6 +97,23 @@ 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\//, "");
|
||||
|
||||
@@ -144,7 +163,10 @@ 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;
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ export const replyActions: Action[] = [
|
||||
},
|
||||
{
|
||||
id: "agents.forkReply",
|
||||
title: "Branch Conversation from Here",
|
||||
title: "Fork 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 copy the task", () => forkTask(task.id, reply.turn + 1));
|
||||
return runReplyOperation(task.id, "Couldn't fork the task", () => forkTask(task.id, reply.turn + 1));
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,70 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -110,6 +110,7 @@ afterEach(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
disposeActions = registerActions([...replyActions, ...taskActions]);
|
||||
useProject.setState({ settings: null });
|
||||
backend.entryError = null;
|
||||
backend.entryWait = null;
|
||||
disconnect();
|
||||
@@ -131,6 +132,7 @@ 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,
|
||||
@@ -158,7 +160,8 @@ 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" });
|
||||
expect(quote).toHaveLength(3);
|
||||
// One per turn: only a turn's final reply carries actions.
|
||||
expect(quote).toHaveLength(2);
|
||||
for (const button of [...quote, ...copy]) {
|
||||
expect(button.textContent).toBe("");
|
||||
expect(button.querySelector("svg path")).not.toBeNull();
|
||||
@@ -209,7 +212,8 @@ describe("conversation rows", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("anchors every agent footer with its time before the hover actions", async () => {
|
||||
it("puts the time and hover actions under a turn's final reply only", async () => {
|
||||
useProject.setState({ settings: { agents: { foldTurns: false } } as never });
|
||||
backend.entries.set("A", [
|
||||
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: 13000 },
|
||||
{ type: "agent", text: "First reply" },
|
||||
@@ -219,17 +223,19 @@ describe("conversation rows", () => {
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Final reply");
|
||||
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();
|
||||
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();
|
||||
});
|
||||
|
||||
it("builds only the requested tail, with absolute indexes", () => {
|
||||
@@ -251,7 +257,7 @@ describe("process folding", () => {
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const summary = await screen.findByText("Reasoning · 2 tool calls");
|
||||
const summary = await screen.findByText("Reasoning · Read 2 files");
|
||||
const fold = summary.closest("details")!;
|
||||
expect(fold.open).toBe(false);
|
||||
// Folded content is rendered once opened, a level at a time.
|
||||
@@ -268,6 +274,50 @@ 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", [
|
||||
@@ -277,7 +327,7 @@ describe("folded content", () => {
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const summary = await screen.findByText("2 tool calls");
|
||||
const summary = await screen.findByText("Read 2 files");
|
||||
expect(screen.queryByText("needle in a tool output")).toBeNull();
|
||||
fireEvent.focus(summary);
|
||||
act(() => focusedSession()!.openFind());
|
||||
@@ -432,7 +482,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("2 tool calls").closest("details")!.open).toBe(false);
|
||||
expect(screen.getByText("Read 2 files").closest("details")!.open).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -441,7 +491,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("2 tool calls");
|
||||
const summary = await screen.findByText("Edited 1 file");
|
||||
expect(summary.closest("details")!.open).toBe(false);
|
||||
expect(screen.getByText("+4")).toBeInTheDocument();
|
||||
expect(screen.getByText("−2")).toBeInTheDocument();
|
||||
@@ -640,7 +690,7 @@ describe("a pending AskUserQuestion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pending permission request in the conversation", () => {
|
||||
describe("a pending permission request above the composer", () => {
|
||||
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" },
|
||||
@@ -668,7 +718,8 @@ describe("a pending permission request in the conversation", () => {
|
||||
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()).toContainElement(card);
|
||||
expect(stream()).not.toContainElement(card);
|
||||
expect(card.closest(`.${styles.tray}`)).not.toBeNull();
|
||||
expect(card.querySelector("pre")?.textContent).toBe(command);
|
||||
const buttons = within(card).getAllByRole("button");
|
||||
expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual([
|
||||
@@ -682,7 +733,7 @@ describe("a pending permission request in the conversation", () => {
|
||||
expect(buttons[1]?.title).toBe(options[1]?.name);
|
||||
});
|
||||
|
||||
it.each(options)("sends the original $kind option id, then collapses the resolved card", async (option) => {
|
||||
it.each(options)("sends the original $kind option id, then records it in the conversation", async (option) => {
|
||||
const pending: AgentEntry = { type: "permission", id: 12, title: `Run ${command}`, options, chosen: null };
|
||||
backend.answers = [];
|
||||
backend.entries.set("A", [pending]);
|
||||
@@ -696,11 +747,31 @@ describe("a pending permission request in the conversation", () => {
|
||||
act(() =>
|
||||
useAgents.setState({ entries: new Map(backend.entries).set("A", [{ ...pending, chosen: option.name }]) }),
|
||||
);
|
||||
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();
|
||||
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"]]));
|
||||
});
|
||||
|
||||
it("is answered from its line too, and one without options can be cancelled", async () => {
|
||||
|
||||
+144
-29
@@ -27,6 +27,7 @@ import {
|
||||
HOME_KIND,
|
||||
SESSION_KIND,
|
||||
TOOL_WINDOW,
|
||||
appendEntry,
|
||||
attentionChange,
|
||||
entriesKey,
|
||||
isBusy,
|
||||
@@ -107,7 +108,12 @@ 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
|
||||
@@ -151,20 +157,37 @@ export function updateDraft(patch: Partial<Draft>) {
|
||||
save(DRAFT_KEY, draft);
|
||||
}
|
||||
|
||||
/** Config for the selected model, with only supported draft choices applied. */
|
||||
export function draftConfig(provider: AgentProvider | undefined, draft: Draft): AgentConfigOption[] {
|
||||
/** 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[] {
|
||||
const config = (provider?.config ?? [])
|
||||
.filter((o) => o.category !== "thought_level")
|
||||
.map((o) => {
|
||||
const choice = draft.config[o.id];
|
||||
return { ...o, current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current };
|
||||
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 model = config.find((o) => o.category === "model")?.current;
|
||||
if (model) {
|
||||
config.push(
|
||||
...(provider?.thoughtLevels?.[model] ?? []).map((o) => {
|
||||
const choice = draft.thoughtLevels?.[model]?.[o.id];
|
||||
return { ...o, current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current };
|
||||
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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -180,7 +203,10 @@ 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 } });
|
||||
@@ -191,7 +217,10 @@ 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,
|
||||
}));
|
||||
}
|
||||
@@ -251,7 +280,11 @@ 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;
|
||||
@@ -274,8 +307,46 @@ 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,
|
||||
@@ -314,13 +385,21 @@ 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;
|
||||
}
|
||||
@@ -329,7 +408,9 @@ 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": {
|
||||
@@ -341,17 +422,22 @@ function onEvent(e: AgentEvent) {
|
||||
) {
|
||||
markUnread(e.task);
|
||||
}
|
||||
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.subagent) streamingReply.set(e.task, { index: e.index, reply: e.entry.type === "agent" });
|
||||
onEntryEvent(s, e);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +458,12 @@ 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),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -381,7 +472,9 @@ 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([
|
||||
@@ -391,15 +484,21 @@ 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, entries), entryLoads };
|
||||
return { entries: new Map(s.entries).set(key, loaded), 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);
|
||||
}
|
||||
@@ -408,7 +507,9 @@ 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, []) }));
|
||||
@@ -445,7 +546,10 @@ 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;
|
||||
@@ -477,7 +581,12 @@ 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));
|
||||
}
|
||||
@@ -541,7 +650,10 @@ 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") {
|
||||
@@ -565,7 +677,7 @@ export async function startTask(
|
||||
providers.find((p) => p.id === draft.provider),
|
||||
draft,
|
||||
);
|
||||
const defaults = bypassDefaults(options);
|
||||
const defaults = { ...bypassDefaults(options), ...providerDefaults(draft.provider) };
|
||||
const config = options.flatMap((o) => {
|
||||
const value =
|
||||
o.category === "model" || o.category === "thought_level"
|
||||
@@ -765,7 +877,10 @@ 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} `;
|
||||
|
||||
@@ -20,6 +20,16 @@ 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;
|
||||
|
||||
@@ -152,13 +162,13 @@ export const taskActions: Action[] = [
|
||||
},
|
||||
{
|
||||
id: "agents.forkTask",
|
||||
title: "Copy Agent Task to a New Task",
|
||||
title: "Fork Agent 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 copy the task"), String(e)));
|
||||
return forkTask(task.id, null).catch((e: unknown) => showError(tx("Couldn't fork the task"), String(e)));
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -200,15 +210,14 @@ 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: "Open in Terminal", icon: "terminal", arg: task.cwd },
|
||||
{ kind: "action", action: "terminal.openAt", label: "Terminal", icon: "terminal", arg: task.cwd },
|
||||
...(task.capabilities?.fork && task.status !== "archived"
|
||||
? ([
|
||||
{
|
||||
kind: "action",
|
||||
action: "agents.forkTask",
|
||||
label: "Copy to a New Task",
|
||||
label: "Fork",
|
||||
icon: "branch",
|
||||
arg: task.id,
|
||||
disabled: !canFork(task),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { TASK_DRAG } from "./model";
|
||||
import {
|
||||
connect,
|
||||
disconnect,
|
||||
loadEntries,
|
||||
dockTask,
|
||||
markVisible,
|
||||
openHome,
|
||||
@@ -30,6 +31,7 @@ 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(),
|
||||
@@ -52,6 +54,7 @@ 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) => ({
|
||||
@@ -149,6 +152,14 @@ 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);
|
||||
@@ -371,3 +382,24 @@ 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);
|
||||
});
|
||||
});
|
||||
@@ -324,6 +324,30 @@ 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");
|
||||
|
||||
@@ -102,14 +102,14 @@ export function activate(): () => void {
|
||||
{
|
||||
id: "build.buildArtifacts",
|
||||
title: "Build Artifacts",
|
||||
description: "Packages the project (mvn package, gradle assemble, cargo build --release, npm run build)",
|
||||
description: "Packages the current module's artifacts, or the whole project when no file is active",
|
||||
category: "Build",
|
||||
when: WHEN,
|
||||
keywords: ["package", "assemble", "jar", "release"],
|
||||
keywords: ["package", "assemble", "jar", "release", "module"],
|
||||
run: ({ arg }) => {
|
||||
const options = typeof arg === "object" && arg !== null ? arg : {};
|
||||
const activate = !("activate" in options && options.activate === false);
|
||||
const build = startBuild("artifacts", null, activate);
|
||||
const build = startBuild("artifacts", moduleTarget(arg), activate);
|
||||
if ("wait" in options && options.wait === true) return build.then(() => undefined);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -212,6 +212,14 @@ 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");
|
||||
|
||||
@@ -75,6 +75,15 @@ 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",
|
||||
@@ -138,6 +147,7 @@ const SCHEMES: Record<string, string> = {
|
||||
singlestore: "singlestore",
|
||||
memsql: "singlestore",
|
||||
oceanbase: "oceanbase",
|
||||
starrocks: "starrocks",
|
||||
oracle: "oracle",
|
||||
oracledb: "oracle",
|
||||
sqlserver: "sqlServer",
|
||||
|
||||
@@ -89,6 +89,7 @@ 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.
|
||||
|
||||
@@ -8,8 +8,38 @@ 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).toEqual({ showContextUsage: true, showWorkTime: true, showUsageLimits: false, showCost: true });
|
||||
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: {} },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -121,9 +121,11 @@ 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, and the tint strength.
|
||||
* platform's caption close and macOS window buttons, the tint strength, and
|
||||
* the selection foreground, whose base default preserves syntax colours.
|
||||
*/
|
||||
const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|tint-strength$)/;
|
||||
const INHERITED_FROM_BASE =
|
||||
/^--(project-color-|db-color-|caption-close-|fg-caption-close-|tint-strength$|fg-editor-selection$)/;
|
||||
|
||||
describe("palette themes", () => {
|
||||
const ISLANDS = ["light", "dark", "dark-hc"];
|
||||
|
||||
@@ -68,6 +68,7 @@ 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",
|
||||
};
|
||||
|
||||
@@ -178,7 +178,20 @@ export const DEFAULT_SETTINGS: ValidSettings = {
|
||||
spelling: { ideWords: [], projectWords: [], customDictionaries: [] },
|
||||
tools: {},
|
||||
languageServers: {},
|
||||
agents: { showContextUsage: true, showWorkTime: true, showUsageLimits: true, showCost: false },
|
||||
agents: {
|
||||
showContextUsage: true,
|
||||
showWorkTime: true,
|
||||
showUsageLimits: true,
|
||||
showCost: false,
|
||||
foldTurns: true,
|
||||
smoothText: true,
|
||||
thinking: "auto",
|
||||
toolCalls: "grouped",
|
||||
expandToolOutput: false,
|
||||
sendWith: "enter",
|
||||
whileRunning: "queue",
|
||||
defaults: {},
|
||||
},
|
||||
mcpServer: { enabled: false },
|
||||
...DEFAULT_WORKFLOW,
|
||||
};
|
||||
@@ -199,6 +212,16 @@ type Obj = Record<string, unknown>;
|
||||
const obj = (v: unknown): Obj => (v && typeof v === "object" && !Array.isArray(v) ? (v as Obj) : {});
|
||||
const bool = (v: unknown, d: boolean) => (typeof v === "boolean" ? v : d);
|
||||
const str = (v: unknown, d: string) => (typeof v === "string" && v.trim() ? v : d);
|
||||
/** Agent id → option id → value, keeping only string values. */
|
||||
function agentDefaults(v: unknown): Record<string, Record<string, string>> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj(v)).map(([agent, options]) => [
|
||||
agent,
|
||||
Object.fromEntries(Object.entries(obj(options)).filter((e): e is [string, string] => typeof e[1] === "string")),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const oneOf = <T extends string>(v: unknown, allowed: readonly T[], d: T): T =>
|
||||
typeof v === "string" && (allowed as readonly string[]).includes(v) ? (v as T) : d;
|
||||
const num = (v: unknown, d: number, { min, max }: { min: number; max: number }, int = false) => {
|
||||
@@ -300,6 +323,14 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
|
||||
showWorkTime: bool(agents.showWorkTime, d.agents.showWorkTime),
|
||||
showUsageLimits: bool(agents.showUsageLimits, d.agents.showUsageLimits),
|
||||
showCost: bool(agents.showCost, d.agents.showCost),
|
||||
foldTurns: bool(agents.foldTurns, d.agents.foldTurns),
|
||||
smoothText: bool(agents.smoothText, d.agents.smoothText),
|
||||
thinking: oneOf(agents.thinking, ["auto", "expanded", "collapsed"] as const, d.agents.thinking),
|
||||
toolCalls: oneOf(agents.toolCalls, ["grouped", "each"] as const, d.agents.toolCalls),
|
||||
expandToolOutput: bool(agents.expandToolOutput, d.agents.expandToolOutput),
|
||||
sendWith: oneOf(agents.sendWith, ["enter", "modEnter"] as const, d.agents.sendWith),
|
||||
whileRunning: oneOf(agents.whileRunning, ["queue", "steer"] as const, d.agents.whileRunning),
|
||||
defaults: agentDefaults(agents.defaults),
|
||||
},
|
||||
mcpServer: { enabled: bool(obj(root.mcpServer).enabled, d.mcpServer.enabled) },
|
||||
...validateWorkflow(root),
|
||||
|
||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user