Compare commits

...
Author SHA1 Message Date
l0ng-ai 9e46d61dc0 feat(agents): incremental streaming, folded turns, approval tray and one Settings › Agents page (#2511)
* feat(agents): stream reply text as appends and repaint only changed blocks

The backend sends only the text added to a streaming message (AgentEvent::Append)
instead of the whole entry every flush. Replies render per top-level Markdown
block, keeping the DOM of blocks that didn't change, and streaming text is
revealed at a steady pace.

* feat(agents): fold finished turns and summarize work by what it did

A finished turn's work before its reply folds into one line under its message
("Reasoning · Ran 1 command · Read 4 files · 12s"), runs of tool calls say what
they did instead of how many there were, and reasoning stays open and bounded
while it streams. New Settings › Agents values choose turn folding, smooth text,
reasoning and tool call display, tool output expansion, the send key and what
sending does while the agent works; per-agent defaults are stored for new tasks.

* feat(agents): ask approvals and show progress above the composer

An approval the agent waits for is asked above the composer, answered with
1, 2, 3… when the focus isn't in a text field, and recorded in the
conversation once answered. Beside it: the plan's progress, the task's changed
files (opening the changes), running subagents and background work, queued
messages, and a line saying what the agent does now and for how long.

* feat(agents): one Settings › Agents page for every agent

Agents get their own settings group. Its page lists every agent this machine
runs, built in or installed from the ACP Registry, with what each needs
(update, not found, custom) beside the selected one's status, sign-in, the
settings its new tasks start with, and how it launches. Add Agent… opens the
ACP Registry in a dialog; Remove and Update act on registry agents in place.
Chat and MCP Server move under it, and Chat gains the sending and conversation
options.

* test(e2e): follow approvals to the tray and work into turn folds

* fix(agents): lay out agent defaults in rows and name reasoning in work summaries

* fix(settings): keep ⌘Enter as written in every locale

* feat(agents): show the ACP Registry as a list beside the selected agent

Add Agent lists registry agents compactly (name, and Installed or Update) and
shows the selected one's version, runtime, authors, description, links and
Install / Update / Remove beside the list, instead of a card per agent with
badges and a button on every row.

* fix(agents): open Add Agent at a fixed size with the list scrolling inside

* feat(agents): list every agent in one searchable list, installed first

Registry agents that can be installed now sit under the installed ones
instead of behind Add Agent. The selected agent's page puts Install,
Update and Remove next to its name and lays out Sign In, New Tasks and
Launch as titled sections with label rows.

* feat(settings): make Agents a group of Coding Agents, Chat and MCP Server

* feat(agents): show each task's agent icon and move chat placement to the options menu

Task rows and session tabs show the icon of the agent the task runs;
rows mark only tasks that are still working or need attention, since
their group already says the rest are ready. Chat in Tool Window and
Chat in Editor move from the header into the tool window's options
menu as a checked pair.

* fix(agents): put actions under a turn's final reply only, and keep selections clean

Interim messages of a turn no longer carry a time and action row, and
the final reply gets one once the turn ends. Code in replies uses a
tint of the text colour instead of an opaque fill, so a selection no
longer leaves light boxes around it, and diagram labels are skipped
when selecting across a reply.

* feat(toolwindows): let a window keep its options out of the title

A tool window can set plainTitle so its own options stay in the "⋮"
menu and its title gets no "▾". The Agents window uses it.

* fix(agents): line up the settings form of an agent

The selects of New Tasks share one width, and the label column is as
wide in every section, so the controls of New Tasks and Launch start
at the same place.

* fix(agents): call copying a task into a new one Fork everywhere

The task menu, the user message and the reply offered the same
operation as Copy to a New Task, Copy to a New Task From Here and
Branch Conversation from Here. They now read Fork Task and Fork from
Here.

* fix(agents): label the fork buttons and menu item just Fork

* fix(agents): label the task's terminal entry just Terminal

* fix(agents): fold the session header into the status line and quiet the chat chrome

- The task's terminal and changes buttons move to the end of the status line above
  the composer; the header row that repeated the tab's title and status is gone.
- The status line leaves out the model when the composer's picker shows it.
- Your messages show their actions and send time beside "You" on hover instead of
  in a row under them, so turns sit closer together.
- Session tabs show a dot only while something waits for you: an unread reply, a
  question or an error. A spinner still marks a running task.
- The composer's attach buttons are plain toolbar icons, the image one monochrome;
  in a narrow composer the model button drops the effort so the model stays readable.

* fix(agents): answer approvals only from the focused session and never cut a turn short to steer

Number keys answer an approval only while the focus is in its session.
With "Sends it into the turn", a message to an agent that can't take it
mid-turn (or a turn waiting for input) is queued instead of cancelling
the turn. A task waiting for you shows a dot instead of a spinner, and a
turn that ran into an error before its reply stays unfolded.

* fix(agents): keep text that streams while a conversation loads

Entry events that arrive while a conversation loads are applied on top
of the loaded copy instead of being lost to it, so a long reply no
longer reloads the whole conversation over and over. A reply that
streams on as appends marks its task unread even when its conversation
isn't open. The backend keeps only the length and end of the text it
sent, rather than a copy it compares in full on every flush.

* fix(agents): render HTML blocks whole and leave drawn diagrams alone while a reply streams

A reply with an HTML block (such as <details> around Markdown) renders
as one piece, so the block wraps what it should. While a reply streams,
only the text after its finished blocks is cut again, and a diagram
already drawn stays in place with its zoom and pan.

* fix(settings): read agent setting values it doesn't know as their defaults

A hand-edited agent setting with an unknown value, or a non-text value
in agent defaults, no longer makes the whole settings file be ignored.

* fix(agents): drop Open from a task's context menu

Clicking a task already opens it.
2026-11-10 19:50:46 +08:00
Deng Liu dba85e8716 perf(vcs): speed up git log indexing, commit details, and blame commit reveal (#2483) 2026-11-10 18:58:09 +08:00
arookieofc c25459317b feat(scheme): support a selection foreground colour (#2464)
* feat(scheme): support a selection foreground colour (#2446)

Settings › Editor › Color Scheme › General › Editor gains a
Selection foreground attribute next to Selection background, wired
to a new --fg-editor-selection token. The editor paints selected
text with it (.cm-openidea-selected, including the diff viewer);
unset it keeps the previous rendering, with syntax colours showing
through (token default: inherit). .icls import/export round trips
it via SELECTION_FOREGROUND.

* test(themes): palette themes inherit selection foreground from base

Solarized/One keep the base default (preserve syntax colours on
selection); record that intent in INHERITED_FROM_BASE.
2026-11-10 18:32:39 +08:00
arookieofc 0d8ad0370a feat(mybatis): navigate namespace, resultMap and sql/include refs (#2461) (#2473)
Mapper XML gains Go to Definition and gutter markers for
<mapper namespace> (the interface), resultMap=/extends= (resultMap
ids, namespace-qualified or same-file) and <include refid=>
(<sql> ids, with refid completion). Links resolve even when the
interface is missing; unresolvable refs link nowhere.

This covers the XML-side navigation of #2461; annotation SQL,
OGNL, type aliases and Java type refs remain open.
2026-11-10 18:23:33 +08:00
arookieofc 3e3088c525 fix(maven): recognize POMs with non-UTF-8 declared encodings (#2495)
Issue #2439's project declares encoding="GBK"; its pom.xml is GBK bytes,
so read_to_string failed and the project was silently skipped (Maven tool
window showed 'No maven project', reload/add did nothing) while CLI Maven
reads it fine. Decode XML files via BOM, the encoding declaration, UTF-8,
then a chardetng guess; use it for POM, parent-chain, source-root and
settings.xml reads.

Refs #2439
2026-11-10 18:12:57 +08:00
arookieofc 331987fc17 fix(vcs): dedupe recent date filters by label (#2454)
* fix(vcs): dedupe recent date filters by label (#2443)

The remembered date preset was compared by JSON, but its rolling
after/before timestamps are recomputed on every selection, so each
switch of the date filter appended a look-alike duplicate that
localStorage kept across restarts. Compare date values (and whole
snapshots) by label instead, and dedupe on read so already-corrupted
state heals itself.

* chore: fix README formatting (pre-existing oxfmt violation blocking CI)
2026-11-10 17:46:10 +08:00
arookieofc e23073c1d4 feat(vcs): show worktree diff in the Compare tool window with Jump to Source and Swap Sides (#2486)
Branch and tag menus' Show Diff with Working Tree opened a dialog (or a
separate comparison window under Tauri). It now shows the worktree diff in
a sidebar Compare tool window instead, with Jump to Source opening the
selected file in the editor and Swap Sides exchanging the two diff sides.

Closes #2442
2026-11-10 17:46:00 +08:00
arookieofc 1699306eaa fix(vcs): make side-by-side diff divider draggable (#2441)
* fix(vcs): make side-by-side diff divider draggable

Allow dragging the middle band between unaligned panes to resize
left/right share (10-90%), with keyboard arrows, double-click
reset and localStorage persistence. Fixes #2437.

* test(vcs): cover diff divider drag, keys and reset

* chore: fix README formatting (pre-existing oxfmt violation blocking CI)
2026-11-10 17:43:29 +08:00
arookieofc c01a538ea4 fix(db): connect to StarRocks via a MySQL-compatible preset (#2463)
* fix(db): connect to StarRocks via a MySQL-compatible preset (#2452)

StarRocks speaks the MySQL wire protocol but rejects the non-constant
SET sql_mode=(SELECT ...) handshake sqlx sends by default. Add a
StarRocks data source preset (MySQL driver, port 9030) that turns off
pipes_as_concat/no_engine_substitution, and recognize the starrocks URL
scheme.

* test(i18n): allowlist StarRocks product name in text catalog
2026-11-10 17:43:18 +08:00
arookieofc 7cc0d5769d fix(boot): ignore ResizeObserver loop also when delivered as an Error (#2474)
* fix(boot): ignore ResizeObserver loop also when delivered as an Error (#2468)

WebKit (Tauri on Linux) can report the benign loop notification with
an Error object attached, which bypassed the bare-message guard and
showed a fatal startup dialog. Match on the engine-generated message
instead; no genuine startup failure carries these strings.

* chore: fix README formatting for oxfmt check
2026-11-10 17:43:08 +08:00
arookieofc 9828f18300 fix(vcs): let Git Log ref labels size to content (#2450)
* fix(vcs): let Git Log ref labels size to content (#2447)

Remove the unconditional 180px per-label cap and let the refs row use
the full subject cell when space allows, shrinking with ellipsis (full
name kept in the title tooltip) only when space truly runs out.

* chore: fix README formatting (pre-existing oxfmt violation blocking CI)
2026-11-10 17:30:16 +08:00
arookieofc c580dd7c69 fix(editor): make Ctrl+wheel font zoom apply to all open editors (#2488)
Ctrl/Cmd+wheel (and the Increase/Decrease Font Size actions) only resized
the focused editor. They now step every open editor to the same value, and
newly opened editors inherit the shared zoom; Reset Font Size resets it.

Closes #2480
2026-11-10 17:30:06 +08:00
arookieofc da3db5cbd6 fix(build): scope Build Artifacts to the active file's module (#2491)
* fix(build): scope Build Artifacts to the active file's module

Build Artifacts always packaged the whole reactor (all 57 modules in the
reporter's project) because the frontend passed a null file. Route it
through moduleTarget like Build Module does, so Maven runs
'-pl <module> -am package' and Gradle runs '<module>:assemble'; with no
active file it still falls back to the whole project. Backend already
supported module-scoped artifacts for Maven and Gradle, no Rust change.

Refs #2476

* fix(build): translate the module-scoped Build Artifacts description

The new action description needs a zh catalog entry (the only complete
locale) and the old text must go from every catalog (the stale-entry
check covers all locales); otherwise textCatalog.test.ts fails.

Refs #2476
2026-11-10 17:29:54 +08:00
arookieofc d1bef52041 fix(keymap): keep Shift+F6 as a rename fallback in the Eclipse preset (#2498)
Alt+Shift+R is swallowed by overlays that hook the Alt+R family (NVIDIA
performance overlay, Xbox Game Bar), leaving Eclipse-preset users with no
way to trigger refactor/project/db rename. Shift+F6 (IntelliJ's rename,
free in this preset) now works as an alternative entry point.

Refs #1876
2026-11-10 17:29:45 +08:00
l0ng-ai 1d4c0e1cd4 docs: remove docs/ and keep plugin docs in plugins/ (#2503)
* docs: remove unreferenced user guides

Drop docs/command-line.md, databases.md, java.md, unity-external-editor.md
and docs/media/debug.jpg. Nothing in the repository links to them.
Plugin, theme and README assets under docs/ stay.

* docs: keep only plugin docs under docs/

Remove docs/themes.md and docs/data-directories.md. Move the upstream
Atom One source revisions into THIRD_PARTY_LICENSES.md, drop the links
to the removed page, and move the README's WeChat QR code to
.github/assets/.

* docs: move plugin docs next to plugins/README.md

docs/plugins/themes.md and indexing.md move to plugins/. Update the
theme doc generator, its test, the plugin template, AGENTS.md and every
link to the new paths.
2026-11-10 16:16:32 +08:00
141 changed files with 5687 additions and 2092 deletions

No files matched your search

File renamed without changes.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

+1 -1
View File
@@ -12,7 +12,7 @@
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"docs/plugins/themes.md",
"plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
"fixtures",
+1 -1
View File
@@ -21,7 +21,7 @@ Visual style: dark, low-contrast greys, one cool accent
- `crates/ide-host` — the `openidea-server` binary for remote development and the
`openidea-plugin` CLI (plugin starter templates live in its `templates.rs`).
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins.
Plugin docs: `plugins/README.md`, `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
View File
@@ -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",
+1 -1
View File
@@ -64,7 +64,7 @@ the [official plugins](plugins/official). The plugin API is still a preview.
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
- WeChat group — scan to join:
<img src="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
+6 -2
View File
@@ -262,7 +262,7 @@ END OF TERMS AND CONDITIONS
The Solarized palettes in `src/styles/tokens.css` are adapted from
[Ethan Schoonover’s Solarized](https://github.com/altercation/solarized/tree/62f656a02f93c5190a8753159e34b385588d5ff3).
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
+82 -2
View File
@@ -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() }
}
}
}
+9
View File
@@ -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,
+36
View File
@@ -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");
}
+2
View File
@@ -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
+23 -5
View File
@@ -499,7 +499,7 @@ pub fn user_settings_file(root: &Path, settings: &ide_core::settings::MavenSetti
/// IDEA lists them): checked by default when `<activeProfiles>` names them or they are
/// `activeByDefault`.
pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
let Some(root) = 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,
+66
View File
@@ -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 (&quote, after) = value.as_bytes().split_first()?;
if quote != b'"' && quote != b'\'' {
continue;
}
let after = std::str::from_utf8(after).ok()?;
let end = after.find(quote as char)?;
if let Some(enc) = Encoding::for_label(after[..end].trim().as_bytes()) {
return Some(enc);
}
}
None
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Element {
pub name: String,
@@ -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"));
}
}
+131 -5
View File
@@ -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();
+18
View File
@@ -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() };
+2 -1
View File
@@ -270,7 +270,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
match scheme.to_ascii_lowercase().as_str() {
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => 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"
+2 -1
View File
@@ -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;
+1 -1
View File
@@ -105,7 +105,7 @@ fn requests(message: &Value) -> Vec<Value> {
async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Result<(StatusCode, Bytes), String> {
let not_running = || {
format!(
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › 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
View File
@@ -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
View File
@@ -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
View File
@@ -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()]);
}
}
+18
View File
@@ -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]
-41
View File
@@ -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.
-27
View File
@@ -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.
-69
View File
@@ -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.
-21
View File
@@ -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
View File
@@ -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.
-42
View File
@@ -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
View File
@@ -61,7 +61,7 @@ const entries: AgentEntry[] = [
for (const theme of ["dark", "light"] as const) {
for (const width of [760, 1400]) {
test(`approval 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"]) {
+2
View File
@@ -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");
+6 -2
View File
@@ -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 -2
View File
@@ -5,14 +5,14 @@ for (const [id, selector] of [
["editor.inspections", "[class*='split'] > [class*='details']"],
["languages.servers", "[class*='body'] > [class*='details']"],
["plugins", "[class*='body'] > [class*='details']"],
["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
View File
@@ -251,7 +251,7 @@ its component with `plugins/build.sh crates/ide-plugin/tests/fixtures/file-templ
`[[contributes.documentIndexers]]` declares file `extensions` and requires the optional
`document-indexer` export. It publishes cross-file associations (definitions, references,
aliases and gutter markers) in background batches, not on the editing path. See
[Background document indexers](../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.
+1 -1
View File
@@ -4,7 +4,7 @@ An OpenIDEA theme plugin, **Harbor Night**: a dark theme with slate surfaces, a
teal accent and a warm syntax palette. It is also where new themes start:
`openidea-plugin new --template theme` copies its theme files, or copy this
folder. Full documentation, the token reference and porting guides are in
[`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",
+1 -1
View File
@@ -105,7 +105,7 @@ dependencies = [
[[package]]
name = "openidea-plugin-openidea-mybatis"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"openidea-plugin",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "openidea-plugin-openidea-mybatis"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
publish = false
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
e79eafa3cef8de7dc8fb5821c3715ceb36c3d4bac92d3b5ca8aedbfd2ec189a1
4ea84cbf4f9202712d8e147dd4631e9d56bf2e324f77de4c67239e9bf5f1f545
+3 -3
View File
@@ -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"
+212 -24
View File
@@ -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();
+9 -8
View File
@@ -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
View File
@@ -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}`);
+2 -2
View File
@@ -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));
+1 -1
View File
@@ -59,5 +59,5 @@ if (scheme) {
console.log(
`${theme.name} (${theme.kind}): ${Object.keys(theme.tokens).length} UI tokens` +
(scheme ? `, ${Object.keys(scheme.tokens).length} editor tokens` : "") +
` → ${values.out}\nUnmapped tokens keep the built-in ${theme.kind} values; see 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.`,
);
+13 -2
View File
@@ -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.
+1 -1
View File
@@ -131,7 +131,7 @@ export function tokenSets(groups: readonly TokenGroup[]): { ui: string[]; scheme
const cell = (v: string) => "`" + v.replace(/\|/g, "\\|") + "`";
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
/** Markdown token reference (one table per group) for 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 = [
+1 -1
View File
@@ -33,7 +33,7 @@ and references. Existing indexers work without another export; declare
cover exactly the name, using UTF-16 columns. The host merges native results,
previews indexed renames and applies them as one undoable workspace edit.
The existing `project.read` capability and trusted-project boundary apply.
See [the indexing contract](../../../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
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -21,7 +21,7 @@ pub struct AppState {
pub plugins: Arc<ide_host::plugins::PluginHost>,
/// The built-in web server (Open In › Browser, the editor's browser bar).
pub web: ide_web::WebServer,
/// 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
View File
@@ -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;
+4 -6
View File
@@ -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();
}
+34 -3
View File
@@ -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;
},
});
+2
View File
@@ -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))",
+77
View File
@@ -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);
});
});
+288
View File
@@ -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;
}
+756
View File
@@ -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;
}
+4 -325
View File
@@ -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>
);
}
+1 -1
View File
@@ -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 }}
+1 -9
View File
@@ -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);
}
+14 -16
View File
@@ -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 -18
View File
@@ -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;
}
-244
View File
@@ -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));
}
+5 -6
View File
@@ -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;
}
+31 -5
View File
@@ -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()}
+3 -1
View File
@@ -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)}>
+56 -5
View File
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks";
import { resetMermaidForTests } from "../viewers/markdown/mermaid";
import { StrictMode } from "react";
import { Markdown } 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="![QR](/tmp/missing.png)" cwd="/repo" />);
await waitFor(() => expect(screen.getByRole("img")).toHaveAttribute("alt", "Unable to load image: /tmp/missing.png"));
expect(screen.getByRole("img")).not.toHaveAttribute("src");
@@ -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");
});
});
+98 -5
View File
@@ -6,7 +6,7 @@
* the drawn diagram hides the code block, and `onDiagramDrawn` lets the conversation
* follow the end when a diagram draws after the last streamed chunk.
*/
import { useLayoutEffect, 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 }}
/>
);
}
+115 -38
View File
@@ -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;
}
+483 -143
View File
@@ -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],
}));
+17 -6
View File
@@ -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>
)}
+170 -40
View File
@@ -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();
});
});
+31
View File
@@ -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]);
}
+108 -13
View File
@@ -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();
});
});
+17 -9
View File
@@ -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.
+25 -3
View File
@@ -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;
}
+2 -2
View File
@@ -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));
},
},
];
+70
View File
@@ -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");
});
});
+57
View File
@@ -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;
}
+96 -25
View File
@@ -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
View File
@@ -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} `;
+14 -5
View File
@@ -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),
+72
View File
@@ -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);
});
});
+146
View File
@@ -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;
}
+32
View File
@@ -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);
});
});
+24
View File
@@ -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");
+3 -3
View File
@@ -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);
},
},
+8
View File
@@ -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");
+10
View File
@@ -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",
+1
View File
@@ -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.
+31 -1
View File
@@ -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",
};
+32 -1
View File
@@ -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