Порівняти коміти

...
Автор SHA1 Повідомлення Дата
l0ng-ai 78d0d6f135 fix(agents): drop Open from a task's context menu
Clicking a task already opens it.
2026-11-10 19:49:44 +08:00
l0ng-ai 7902fb58be 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.
2026-11-10 19:45:31 +08:00
l0ng-ai 8cef22a83d 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.
2026-11-10 19:45:29 +08:00
l0ng-ai 08ba4fcfc5 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.
2026-11-10 19:45:28 +08:00
l0ng-ai 9fd88fc414 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.
2026-11-10 19:45:20 +08:00
l0ng-ai d08061907f 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.
2026-11-10 19:11:51 +08:00
l0ng-ai e69442217c fix(agents): label the task's terminal entry just Terminal 2026-11-10 18:53:25 +08:00
l0ng-ai 919c9d7056 fix(agents): label the fork buttons and menu item just Fork 2026-11-10 18:51:46 +08:00
l0ng-ai f6a3a65691 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.
2026-11-10 18:47:47 +08:00
l0ng-ai 0636ae6550 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.
2026-11-10 18:46:02 +08:00
l0ng-ai 238aa71f1a 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.
2026-11-10 18:46:01 +08:00
l0ng-ai 098b296159 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.
2026-11-10 18:44:21 +08:00
l0ng-ai f6af6f7172 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.
2026-11-10 18:44:19 +08:00
l0ng-ai d61386d4b9 feat(settings): make Agents a group of Coding Agents, Chat and MCP Server 2026-11-10 18:30:58 +08:00
l0ng-ai a7e1ad26f3 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.
2026-11-10 18:28:03 +08:00
l0ng-ai 3ffb897f48 fix(agents): open Add Agent at a fixed size with the list scrolling inside 2026-11-10 18:16:45 +08:00
l0ng-ai db1238cdd5 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.
2026-11-10 18:14:14 +08:00
l0ng-ai 401325b8c4 fix(settings): keep ⌘Enter as written in every locale 2026-11-10 17:16:29 +08:00
l0ng-ai b33bb19807 fix(agents): lay out agent defaults in rows and name reasoning in work summaries 2026-11-10 17:10:31 +08:00
l0ng-ai 36261f0190 test(e2e): follow approvals to the tray and work into turn folds 2026-11-10 17:06:50 +08:00
l0ng-ai ca339c4934 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.
2026-11-10 17:01:05 +08:00
l0ng-ai 338dc71d20 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.
2026-11-10 16:50:03 +08:00
l0ng-ai 7b3acb192f 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.
2026-11-10 16:46:16 +08:00
l0ng-ai 27c3f5ebaa 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.
2026-11-10 16:33:38 +08:00
64 змінених файлів з 3563 додано та 1362 видалено

No files matched your search

+82 -2
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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");
}
+131 -5
Переглянути файл
@@ -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();
+1 -1
Переглянути файл
@@ -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()
)
};
+19 -15
Переглянути файл
@@ -61,7 +61,7 @@ const entries: AgentEntry[] = [
for (const theme of ["dark", "light"] as const) {
for (const width of [760, 1400]) {
test(`approval stays inside the agent column at ${width}px in ${theme}`, async ({ page }) => {
test(`approval is asked above the composer at ${width}px in ${theme}`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto("/");
await page.getByRole("tree", { name: "Project" }).waitFor();
@@ -85,43 +85,47 @@ for (const theme of ["dark", "light"] as const) {
await expect(card.locator("pre")).toHaveText(command);
await expect(card.getByRole("button")).toHaveText(["Allow Once", "Always Allow", "Decline", "Decline and Stop"]);
const geometry = await card.evaluate((el) => {
const transcript = el.parentElement!.parentElement!;
// Asked above the composer, below the conversation.
const area = el.closest('[class*="_composerArea_"]')!;
const transcript = area.parentElement!.querySelector('[class*="_stream_"]')!;
const prompt = transcript.querySelector('[class*="_user_"]')!;
const reply = transcript.querySelector('[class*="_agentCard_"]')!;
const rect = el.getBoundingClientRect();
const bounds = transcript.getBoundingClientRect();
const bounds = area.getBoundingClientRect();
return {
scroll: transcript.scrollWidth,
client: transcript.clientWidth,
scroll: area.scrollWidth,
client: area.clientWidth,
inStream: transcript.contains(el),
contained: rect.left >= bounds.left && rect.right <= bounds.right,
below: rect.top >= transcript.getBoundingClientRect().bottom - 1,
promptRadius: parseFloat(getComputedStyle(prompt).borderRadius),
replyBackground: getComputedStyle(reply).backgroundColor,
replyBorder: getComputedStyle(reply).borderTopWidth,
userOnRight: prompt.getBoundingClientRect().right > reply.getBoundingClientRect().right,
approvalAligned: Math.abs(rect.left - reply.getBoundingClientRect().left) < 1,
authorBeforeWork:
transcript.querySelector('[class*="_agentAuthor_"]')?.nextElementSibling?.matches("details") ?? false,
footerAligned:
Math.abs(prompt.getBoundingClientRect().right - prompt.nextElementSibling!.getBoundingClientRect().right) <
1,
headAligned:
Math.abs(
prompt.getBoundingClientRect().right - prompt.previousElementSibling!.getBoundingClientRect().right,
) < 1,
cardWidth: rect.width,
available: bounds.width,
actionsVisible: getComputedStyle(reply.nextElementSibling!.querySelector('[class*="_messageActions_"]')!)
.opacity,
replyFooter: reply.nextElementSibling != null,
};
});
expect(geometry.scroll).toBe(geometry.client);
expect(geometry.inStream).toBe(false);
expect(geometry.contained).toBe(true);
expect(geometry.below).toBe(true);
expect(geometry.promptRadius).toBeLessThanOrEqual(6);
// Replies read as plain text on the panel.
expect(geometry.replyBackground).toBe("rgba(0, 0, 0, 0)");
expect(geometry.replyBorder).toBe("0px");
expect(geometry.userOnRight).toBe(true);
expect(geometry.approvalAligned).toBe(true);
expect(geometry.authorBeforeWork).toBe(true);
expect(geometry.footerAligned).toBe(true);
// The last message stays actionable even while an approval follows it.
expect(geometry.actionsVisible).toBe("1");
expect(geometry.headAligned).toBe(true);
// The turn waits on the approval: its interim message has no footer yet.
expect(geometry.replyFooter).toBe(false);
if (width === 760) expect(geometry.cardWidth).toBeGreaterThan(geometry.available * 0.8);
await card.locator("pre").focus();
for (const label of ["Allow Once", "Always Allow", "Decline", "Decline and Stop"]) {
+2
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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" },
},
];
}
+2 -2
Переглянути файл
@@ -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
Переглянути файл
@@ -21,7 +21,7 @@ pub struct AppState {
pub plugins: Arc<ide_host::plugins::PluginHost>,
/// The built-in web server (Open In › Browser, the editor's browser bar).
pub web: ide_web::WebServer,
/// Tools › MCP Server: built-in IDE tools for agents.
/// Agents › MCP Server: built-in IDE tools for agents.
pub mcp: crate::commands::mcp::McpService,
/// Credential prompts of `ssh` / `git` child processes (`None` if it failed to start).
pub askpass: Option<ide_ssh::askpass::AskpassServer>,
+79 -10
Переглянути файл
@@ -1657,6 +1657,11 @@ export type AgentEvent =
{ type: "truncated"; task: string; length: number } |
/** Entry `index` of `task` (of its subagent `subagent`) was added or replaced. */
{ type: "entry"; task: string; subagent: string | null; index: number; entry: AgentEntry } |
/**
* Text was appended to entry `index` (an agent message or a thought): it read the first
* `from` UTF-16 units of its text when the previous event for it was sent.
*/
{ type: "append"; task: string; subagent: string | null; index: number; from: number; text: string } |
/** An agent reported the usage limits of its account (shared by every task of `provider`). */
{ type: "limits"; provider: string; limits: AgentLimit[] } |
/** The installed or configured agent providers changed. */
@@ -1780,8 +1785,11 @@ export type AgentRetry = {
error: string | null,
};
/** What the meters in an agent task's header show. */
export type AgentSettings = {
/** Settings › Agents: what the conversation shows and how messages are sent. */
export type AgentSettings = AgentSettings_Serialize | AgentSettings_Deserialize;
/** Settings › Agents: what the conversation shows and how messages are sent. */
export type AgentSettings_Deserialize = {
/** Context window use. */
showContextUsage?: boolean,
/** Time the agent spent on the task. */
@@ -1790,6 +1798,50 @@ export type AgentSettings = {
showUsageLimits?: boolean,
/** What the session has cost so far, as the agent reports it. */
showCost?: boolean,
/** A finished turn's work folds into one line under its message, leaving the reply. */
foldTurns?: boolean,
/** Streaming text is revealed at a steady pace rather than chunk by chunk. */
smoothText?: boolean,
thinking?: ThinkingDisplay,
toolCalls?: ToolCallDisplay,
/** A tool call's output (a command's, a diff) is shown without opening it. */
expandToolOutput?: boolean,
sendWith?: SendKey,
/**
* What sending does while the agent works: queue the message for after the turn, or
* send it into the turn.
*/
whileRunning?: WhileRunning,
/** Settings new tasks of an agent start with: agent id → option id → value. */
defaults?: { [key in string]: { [key in string]: string } },
};
/** Settings › Agents: what the conversation shows and how messages are sent. */
export type AgentSettings_Serialize = {
/** Context window use. */
showContextUsage: boolean,
/** Time the agent spent on the task. */
showWorkTime: boolean,
/** The account's usage-limit windows (Claude's 5-hour and weekly limits). */
showUsageLimits: boolean,
/** What the session has cost so far, as the agent reports it. */
showCost: boolean,
/** A finished turn's work folds into one line under its message, leaving the reply. */
foldTurns: boolean,
/** Streaming text is revealed at a steady pace rather than chunk by chunk. */
smoothText: boolean,
thinking: ThinkingDisplay,
toolCalls: ToolCallDisplay,
/** A tool call's output (a command's, a diff) is shown without opening it. */
expandToolOutput: boolean,
sendWith: SendKey,
/**
* What sending does while the agent works: queue the message for after the turn, or
* send it into the turn.
*/
whileRunning: WhileRunning,
/** Settings new tasks of an agent start with: agent id → option id → value. */
defaults: { [key in string]: { [key in string]: string } },
};
/** Where an agent comes from. */
@@ -5994,13 +6046,13 @@ export type MavenSettings = {
activeProfiles?: string,
};
/** Tools › MCP Server: agents use the IDE's built-in tools. */
/** Agents › MCP Server: agents use the IDE's built-in tools. */
export type McpServerSettings = {
/** Serve the tools on 127.0.0.1 while the IDE runs. */
enabled?: boolean,
};
/** What Settings › Tools › MCP Server shows. */
/** What Settings › Agents › MCP Server shows. */
export type McpServerStatus = {
running: boolean,
/** Why it isn't running though turned on. */
@@ -7899,6 +7951,9 @@ export type SemanticTokens = {
tokenModifiers: string[],
};
/** The key that sends a message; the other one starts a new line. */
export type SendKey = "enter" | "modEnter";
/** A running language server instance. */
export type ServerId = number;
@@ -8035,9 +8090,9 @@ export type Settings_Deserialize = {
/** Build, Execution, Deployment › Run Targets. */
runTargets?: RunTarget[],
cmake?: CMakeSettings_Deserialize,
/** Tools › Agents › Chat. */
agents?: AgentSettings,
/** Tools › MCP Server (global only). */
/** Settings › Agents. */
agents?: AgentSettings_Deserialize,
/** Agents › MCP Server (global only). */
mcpServer?: McpServerSettings,
/** Plugin settings (`[contributes.settings]`): plugin id → its values by key. */
plugins?: { [key in string]: { [key in string]: unknown } },
@@ -8097,9 +8152,9 @@ export type Settings_Serialize = {
/** Build, Execution, Deployment › Run Targets. */
runTargets: RunTarget[],
cmake: CMakeSettings_Serialize,
/** Tools › Agents › Chat. */
agents: AgentSettings,
/** Tools › MCP Server (global only). */
/** Settings › Agents. */
agents: AgentSettings_Serialize,
/** Agents › MCP Server (global only). */
mcpServer: McpServerSettings,
/** Plugin settings (`[contributes.settings]`): plugin id → its values by key. */
plugins: { [key in string]: { [key in string]: unknown } },
@@ -8673,6 +8728,11 @@ export type ThemeDef = {
export type ThemeKind = "dark" | "light";
/** How the agent's reasoning shows. */
export type ThinkingDisplay =
/** Open while it streams, folded once it ends. */
"auto" | "expanded" | "collapsed";
export type ThreadInfo = {
id: number,
name: string,
@@ -8720,6 +8780,13 @@ export type TomcatInstallation_Serialize = {
tomee: boolean,
};
/** How tool calls between messages show. */
export type ToolCallDisplay =
/** A run of calls is one line saying what they did ("Read 4 files · Edited 2 files"). */
"grouped" |
/** Each call on its own line. */
"each";
/** UI-facing state of one tool. */
export type ToolInfo = {
/** Server / adapter name from `languages/*.toml`. */
@@ -9066,6 +9133,8 @@ export type WebReferenceKind =
/** `var(--name)`: the custom property's declarations. */
"cssVariable";
export type WhileRunning = "queue" | "steer";
/** A request that the invoking window acknowledges through plugin_answer_command. */
export type WindowRequest = WindowRequest_Serialize | WindowRequest_Deserialize;
+288
Переглянути файл
@@ -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
Переглянути файл
@@ -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>
);
}
-131
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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>
)
-123
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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"]);
});
});
+21
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -110,6 +110,7 @@ afterEach(() => {
beforeEach(() => {
disposeActions = registerActions([...replyActions, ...taskActions]);
useProject.setState({ settings: null });
backend.entryError = null;
backend.entryWait = null;
disconnect();
@@ -131,6 +132,7 @@ beforeEach(() => {
describe("conversation rows", () => {
it("keeps authors above messages and their folded work with matching icon-only actions", async () => {
useProject.setState({ settings: { agents: { foldTurns: false } } as never });
const user = (text: string): AgentEntry => ({
type: "user",
text,
@@ -158,7 +160,8 @@ describe("conversation rows", () => {
expect(author.parentElement?.nextElementSibling?.firstElementChild).toHaveClass(styles.agentMessage!);
const quote = within(stream()).getAllByRole("button", { name: "Quote Reply" });
const copy = within(stream()).getAllByRole("button", { name: "Copy Markdown" });
expect(quote).toHaveLength(3);
// One per turn: only a turn's final reply carries actions.
expect(quote).toHaveLength(2);
for (const button of [...quote, ...copy]) {
expect(button.textContent).toBe("");
expect(button.querySelector("svg path")).not.toBeNull();
@@ -209,7 +212,8 @@ describe("conversation rows", () => {
},
);
it("anchors every agent footer with its time before the hover actions", async () => {
it("puts the time and hover actions under a turn's final reply only", async () => {
useProject.setState({ settings: { agents: { foldTurns: false } } as never });
backend.entries.set("A", [
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: 13000 },
{ type: "agent", text: "First reply" },
@@ -219,17 +223,19 @@ describe("conversation rows", () => {
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
await screen.findByText("Final reply");
const messages = stream().querySelectorAll(`.${styles.agentMessage}`);
expect(messages).toHaveLength(2);
for (const message of messages) {
const [card, footer] = message.children;
expect(card).toHaveClass(styles.agentCard!);
expect(footer).toHaveClass(styles.messageFooter!);
expect(footer?.firstElementChild).toHaveClass(styles.messageTime!);
expect(footer?.firstElementChild).toHaveTextContent("12s");
expect(footer?.lastElementChild).toHaveClass(styles.messageActions!);
}
expect(stream().querySelector(`.${styles.userMessage} > .${styles.messageFooter}`)).not.toBeNull();
const [first, last] = stream().querySelectorAll(`.${styles.agentMessage}`);
expect(first?.children).toHaveLength(1);
const [card, footer] = last!.children;
expect(card).toHaveClass(styles.agentCard!);
expect(footer).toHaveClass(styles.messageFooter!);
expect(footer?.firstElementChild).toHaveClass(styles.messageTime!);
expect(footer?.firstElementChild).toHaveTextContent("12s");
expect(footer?.lastElementChild).toHaveClass(styles.messageActions!);
// Your message keeps its actions and time beside "You", above it.
expect(stream().querySelector(`.${styles.userMessage} > .${styles.messageFooter}`)).toBeNull();
expect(
stream().querySelector(`.${styles.userMessage} > .${styles.messageHead} .${styles.messageActions}`),
).not.toBeNull();
});
it("builds only the requested tail, with absolute indexes", () => {
@@ -251,7 +257,7 @@ describe("process folding", () => {
]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const summary = await screen.findByText("Reasoning · 2 tool calls");
const summary = await screen.findByText("Reasoning · Read 2 files");
const fold = summary.closest("details")!;
expect(fold.open).toBe(false);
// Folded content is rendered once opened, a level at a time.
@@ -268,6 +274,50 @@ describe("process folding", () => {
});
});
describe("turn folding", () => {
it("folds a finished turn's work under its message and keeps the reply", async () => {
backend.entries.set("A", [
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: 13000 },
{ type: "thought", text: "think" },
tool("one"),
{ type: "agent", text: "Interim note" },
tool("two", { kind: "execute" }),
{ type: "agent", text: "Final reply" },
]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
await screen.findByText("Final reply");
const summary = screen.getByText("Reasoning · Ran 1 command · Read 1 file · 1 message");
const fold = summary.closest("details")!;
expect(fold).toHaveClass(styles.turnFold!);
expect(fold).toHaveTextContent("12s");
expect(screen.queryByText("Interim note")).toBeNull();
fold.open = true;
fireEvent(fold, new Event("toggle"));
expect(await screen.findByText("Interim note")).toBeInTheDocument();
});
it("leaves the turn the agent works on open", async () => {
backend.entries.set("A", [
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: null },
tool("one"),
{ type: "agent", text: "Interim note" },
]);
useAgents.setState({
entries: new Map(backend.entries),
tasks: new Map(useAgents.getState().tasks).set("A", {
...useAgents.getState().tasks.get("A")!,
status: "running",
}),
});
render(<SessionEditor input={input("A")} />);
expect(await screen.findByText("Interim note")).toBeInTheDocument();
expect(stream().querySelector(`.${styles.turnFold}`)).toBeNull();
// The turn isn't over: its latest message is no final reply yet.
expect(stream().querySelector(`.${styles.agentMessage} > .${styles.messageFooter}`)).toBeNull();
});
});
describe("folded content", () => {
it("is rendered for Find in Conversation, so its text is found", async () => {
backend.entries.set("A", [
@@ -277,7 +327,7 @@ describe("folded content", () => {
]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const summary = await screen.findByText("2 tool calls");
const summary = await screen.findByText("Read 2 files");
expect(screen.queryByText("needle in a tool output")).toBeNull();
fireEvent.focus(summary);
act(() => focusedSession()!.openFind());
@@ -432,7 +482,7 @@ describe("tool calls", () => {
const output = await screen.findByText("Reading files…");
expect(output.closest("details")!.open).toBe(true);
expect(output.closest(`.${styles.processRun}`)).toBeNull();
expect(screen.getByText("2 tool calls").closest("details")!.open).toBe(false);
expect(screen.getByText("Read 2 files").closest("details")!.open).toBe(false);
},
);
@@ -441,7 +491,7 @@ describe("tool calls", () => {
backend.entries.set("A", [edit("one"), edit("two"), { type: "agent", text: "Answer A" }]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const summary = await screen.findByText("2 tool calls");
const summary = await screen.findByText("Edited 1 file");
expect(summary.closest("details")!.open).toBe(false);
expect(screen.getByText("+4")).toBeInTheDocument();
expect(screen.getByText("−2")).toBeInTheDocument();
@@ -640,7 +690,7 @@ describe("a pending AskUserQuestion", () => {
});
});
describe("a pending permission request in the conversation", () => {
describe("a pending permission request above the composer", () => {
const command = 'python3 -c \'from pathlib import Path\np = Path("note.txt")\np.write_text("one\\ntwo")\'';
const options = [
{ id: "opaque-once", name: "Allow Once", kind: "allow_once" },
@@ -668,7 +718,8 @@ describe("a pending permission request in the conversation", () => {
const card = await screen.findByRole("region", { name: "codex wants to run a command" });
expect(screen.getAllByRole("region", { name: "codex wants to run a command" })).toHaveLength(1);
expect(screen.queryByRole("alert")).toBeNull();
expect(stream()).toContainElement(card);
expect(stream()).not.toContainElement(card);
expect(card.closest(`.${styles.tray}`)).not.toBeNull();
expect(card.querySelector("pre")?.textContent).toBe(command);
const buttons = within(card).getAllByRole("button");
expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual([
@@ -682,7 +733,7 @@ describe("a pending permission request in the conversation", () => {
expect(buttons[1]?.title).toBe(options[1]?.name);
});
it.each(options)("sends the original $kind option id, then collapses the resolved card", async (option) => {
it.each(options)("sends the original $kind option id, then records it in the conversation", async (option) => {
const pending: AgentEntry = { type: "permission", id: 12, title: `Run ${command}`, options, chosen: null };
backend.answers = [];
backend.entries.set("A", [pending]);
@@ -696,11 +747,31 @@ describe("a pending permission request in the conversation", () => {
act(() =>
useAgents.setState({ entries: new Map(backend.entries).set("A", [{ ...pending, chosen: option.name }]) }),
);
expect(card.querySelector("pre")).toBeNull();
expect(within(card).queryByRole("button")).toBeNull();
expect(card.textContent).toContain("python3 …");
expect(card.textContent).not.toContain("write_text");
expect(card).toHaveFocus();
expect(card).not.toBeInTheDocument();
const record = within(stream()).getByRole("region", { name: "codex wants to run a command" });
expect(record.querySelector("pre")).toBeNull();
expect(within(record).queryByRole("button")).toBeNull();
expect(record.textContent).toContain("python3 …");
expect(record.textContent).not.toContain("write_text");
await waitFor(() => expect(document.activeElement?.tagName).toBe("TEXTAREA"));
});
it("is answered with a number key when the focus isn't in a text field", async () => {
const pending: AgentEntry = { type: "permission", id: 12, title: `Run ${command}`, options, chosen: null };
backend.answers = [];
backend.entries.set("A", [pending]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
await screen.findByRole("region", { name: "codex wants to run a command" });
fireEvent.keyDown(document.querySelector("textarea")!, { key: "1" });
expect(backend.answers).toEqual([]);
const elsewhere = document.body.appendChild(document.createElement("div"));
fireEvent.keyDown(elsewhere, { key: "1" });
fireEvent.keyDown(document.body, { key: "2" });
elsewhere.remove();
expect(backend.answers).toEqual([]);
fireEvent.keyDown(stream(), { key: "3" });
await waitFor(() => expect(backend.answers).toEqual([["A", 12, "opaque-no"]]));
});
it("is answered from its line too, and one without options can be cancelled", async () => {
+144 -29
Переглянути файл
@@ -27,6 +27,7 @@ import {
HOME_KIND,
SESSION_KIND,
TOOL_WINDOW,
appendEntry,
attentionChange,
entriesKey,
isBusy,
@@ -107,7 +108,12 @@ function save(key: string, value: unknown) {
}
}
const fallbackDraft: Draft = { provider: "claude", config: {}, isolation: "local", base: null };
const fallbackDraft: Draft = {
provider: "claude",
config: {},
isolation: "local",
base: null,
};
/**
* The saved New Task settings. A worktree is used only when the user picked it: drafts
@@ -151,20 +157,37 @@ export function updateDraft(patch: Partial<Draft>) {
save(DRAFT_KEY, draft);
}
/** Config for the selected model, with only supported draft choices applied. */
export function draftConfig(provider: AgentProvider | undefined, draft: Draft): AgentConfigOption[] {
/** What Settings › Agents says new tasks of `provider` start with, by option id. */
export const providerDefaults = (provider: string | undefined): Record<string, string> =>
(provider && useProject.getState().settings?.agents?.defaults?.[provider]) || {};
/**
* Config for the selected model, with only supported choices applied: the draft's, else
* the agent's defaults from Settings › Agents.
*/
export function draftConfig(
provider: AgentProvider | undefined,
draft: Draft,
defaults: Record<string, string> = providerDefaults(provider?.id),
): AgentConfigOption[] {
const config = (provider?.config ?? [])
.filter((o) => o.category !== "thought_level")
.map((o) => {
const choice = draft.config[o.id];
return { ...o, current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current };
const choice = draft.config[o.id] ?? defaults[o.id];
return {
...o,
current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current,
};
});
const model = config.find((o) => o.category === "model")?.current;
if (model) {
config.push(
...(provider?.thoughtLevels?.[model] ?? []).map((o) => {
const choice = draft.thoughtLevels?.[model]?.[o.id];
return { ...o, current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current };
const choice = draft.thoughtLevels?.[model]?.[o.id] ?? defaults[o.id];
return {
...o,
current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current,
};
}),
);
}
@@ -180,7 +203,10 @@ export function setDraftOption(id: string, value: string) {
const model = config.find((o) => o.category === "model")?.current;
if (model && config.find((o) => o.id === id)?.category === "thought_level") {
updateDraft({
thoughtLevels: { ...draft.thoughtLevels, [model]: { ...draft.thoughtLevels?.[model], [id]: value } },
thoughtLevels: {
...draft.thoughtLevels,
[model]: { ...draft.thoughtLevels?.[model], [id]: value },
},
});
} else {
updateDraft({ config: { ...draft.config, [id]: value } });
@@ -191,7 +217,10 @@ export const composerDraft = (key: string) => useAgents.getState().composers.get
export function updateComposer(key: string, patch: Partial<ComposerDraft>) {
useAgents.setState((s) => ({
composers: new Map(s.composers).set(key, { ...(s.composers.get(key) ?? emptyDraft()), ...patch }),
composers: new Map(s.composers).set(key, {
...(s.composers.get(key) ?? emptyDraft()),
...patch,
}),
lastComposer: key,
}));
}
@@ -251,7 +280,11 @@ export async function connect(): Promise<void> {
: call(commands.agentProviders()),
]);
} catch (e) {
if (gen === generation) useAgents.setState({ loaded: true, error: e instanceof Error ? e.message : String(e) });
if (gen === generation)
useAgents.setState({
loaded: true,
error: e instanceof Error ? e.message : String(e),
});
return;
}
if (gen !== generation) return;
@@ -274,8 +307,46 @@ export async function connect(): Promise<void> {
}
}
type EntryEvent = Extract<AgentEvent, { type: "entry" | "append" }>;
/** Entry events that arrived while their conversation loaded, applied on top of the copy it loads. */
const arrivedWhileLoading = new Map<string, EntryEvent[]>();
/** Whether the message each task streams now is the agent's reply, known without its conversation. */
const streamingReply = new Map<string, { index: number; reply: boolean }>();
/** `entries` with the event applied; `null` when the copy can't take it. */
function applyEntryEvent(entries: AgentEntry[], e: EntryEvent): AgentEntry[] | null {
if (e.type === "append") return appendEntry(entries, e.index, e.from, e.text);
const have = entries[e.index];
// Sent before the loaded copy was read: that copy already holds more of the message.
if (
have &&
(have.type === "agent" || have.type === "thought") &&
e.entry.type === have.type &&
have.text.length > e.entry.text.length &&
have.text.startsWith(e.entry.text)
)
return entries;
return upsertEntry(entries, e.index, e.entry);
}
function onEntryEvent(s: AgentsState, e: EntryEvent) {
const key = entriesKey(e.task, e.subagent);
if (s.entryLoads.get(key)?.error === null) {
arrivedWhileLoading.set(key, [...(arrivedWhileLoading.get(key) ?? []), e]);
return;
}
const current = s.entries.get(key);
if (!current) return;
const next = applyEntryEvent(current, e);
if (!next) void loadEntries(e.task, e.subagent);
else if (next !== current) useAgents.setState({ entries: new Map(s.entries).set(key, next) });
}
export function disconnect() {
generation++;
arrivedWhileLoading.clear();
streamingReply.clear();
useAgents.setState({
loaded: false,
error: null,
@@ -314,13 +385,21 @@ function onEvent(e: AgentEvent) {
composers.delete(e.id);
const unread = new Set(s.unread);
unread.delete(e.id);
useAgents.setState({ tasks, composers, unread, ...(s.docked === e.id ? { docked: null } : {}) });
useAgents.setState({
tasks,
composers,
unread,
...(s.docked === e.id ? { docked: null } : {}),
});
void editorService().closeInput?.(sessionKey(e.id));
break;
}
case "truncated": {
const current = s.entries.get(e.task);
if (current) useAgents.setState({ entries: new Map(s.entries).set(e.task, current.slice(0, e.length)) });
if (current)
useAgents.setState({
entries: new Map(s.entries).set(e.task, current.slice(0, e.length)),
});
scheduleChanges(e.task);
break;
}
@@ -329,7 +408,9 @@ function onEvent(e: AgentEvent) {
break;
}
case "limits": {
useAgents.setState({ limits: new Map(s.limits).set(e.provider, e.limits) });
useAgents.setState({
limits: new Map(s.limits).set(e.provider, e.limits),
});
break;
}
case "entry": {
@@ -341,17 +422,22 @@ function onEvent(e: AgentEvent) {
) {
markUnread(e.task);
}
const key = entriesKey(e.task, e.subagent);
const current = s.entries.get(key);
if (!current) return;
const next = upsertEntry(current, e.index, e.entry);
if (next) useAgents.setState({ entries: new Map(s.entries).set(key, next) });
else void loadEntries(e.task, e.subagent);
if (!e.subagent) streamingReply.set(e.task, { index: e.index, reply: e.entry.type === "agent" });
onEntryEvent(s, e);
if (e.entry.type === "tool" && e.entry.status === "completed" && e.entry.diffs.length) {
scheduleChanges(e.task);
}
break;
}
case "append": {
if (!e.subagent && e.text.trim()) {
const shown = s.entries.get(entriesKey(e.task, null))?.[e.index];
const streaming = streamingReply.get(e.task);
if (shown ? shown.type === "agent" : streaming?.index === e.index && streaming.reply) markUnread(e.task);
}
onEntryEvent(s, e);
break;
}
}
}
@@ -372,7 +458,12 @@ function notifyChange(prev: AgentTask | undefined, task: AgentTask) {
key: `agent:${task.id}`,
group: "tools",
sticky: change !== "finished",
actions: [{ label: change === "finished" ? "Review" : "Open", run: () => openTask(task.id) }],
actions: [
{
label: change === "finished" ? "Review" : "Open",
run: () => openTask(task.id),
},
],
});
}
@@ -381,7 +472,9 @@ export async function loadEntries(task: string, subagent: string | null = null):
const key = entriesKey(task, subagent);
if (useAgents.getState().entryLoads.get(key)?.error === null) return;
const load = { error: null };
useAgents.setState((s) => ({ entryLoads: new Map(s.entryLoads).set(key, load) }));
useAgents.setState((s) => ({
entryLoads: new Map(s.entryLoads).set(key, load),
}));
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const entries = await Promise.race([
@@ -391,15 +484,21 @@ export async function loadEntries(task: string, subagent: string | null = null):
}),
]);
if (useAgents.getState().entryLoads.get(key) !== load) return;
let loaded = entries;
for (const event of arrivedWhileLoading.get(key) ?? []) loaded = applyEntryEvent(loaded, event) ?? loaded;
arrivedWhileLoading.delete(key);
useAgents.setState((s) => {
const entryLoads = new Map(s.entryLoads);
entryLoads.delete(key);
return { entries: new Map(s.entries).set(key, entries), entryLoads };
return { entries: new Map(s.entries).set(key, loaded), entryLoads };
});
} catch (e) {
if (useAgents.getState().entryLoads.get(key) !== load) return;
arrivedWhileLoading.delete(key);
const error = (e instanceof Error ? e.message : String(e)).trim() || tx("Unknown error");
useAgents.setState((s) => ({ entryLoads: new Map(s.entryLoads).set(key, { error }) }));
useAgents.setState((s) => ({
entryLoads: new Map(s.entryLoads).set(key, { error }),
}));
} finally {
clearTimeout(timer);
}
@@ -408,7 +507,9 @@ export async function loadEntries(task: string, subagent: string | null = null):
export async function refreshChanges(task: string): Promise<void> {
try {
const changes = await call(commands.agentChanges(task));
useAgents.setState((s) => ({ changes: new Map(s.changes).set(task, changes) }));
useAgents.setState((s) => ({
changes: new Map(s.changes).set(task, changes),
}));
} catch {
// The worktree is gone or not a repository: nothing to list.
useAgents.setState((s) => ({ changes: new Map(s.changes).set(task, []) }));
@@ -445,7 +546,10 @@ export function useAgentCommands(
task: string | null,
offered: readonly AgentCommand[],
): readonly AgentCommand[] {
const [found, setFound] = useState<{ for: unknown[]; list: readonly AgentCommand[] } | null>(null);
const [found, setFound] = useState<{
for: unknown[];
list: readonly AgentCommand[];
} | null>(null);
useEffect(() => {
if (!provider || !isTauri()) return;
let alive = true;
@@ -477,7 +581,12 @@ function openTaskInEditor(task: string) {
}
function showDockedTask(task: string) {
useAgents.setState({ docked: task, lastTask: task, homeOpen: false, lastComposer: task });
useAgents.setState({
docked: task,
lastTask: task,
homeOpen: false,
lastComposer: task,
});
toolWindowService().show(TOOL_WINDOW, true);
void editorService().closeInput?.(sessionKey(task));
}
@@ -541,7 +650,10 @@ export function openHome(prompt?: string) {
if (project?.workspace) {
const previous = useAgents.getState().draft.projectRoot;
const projectRoot = defaultTaskProject(project, editorService().activePath(), previous);
updateDraft({ projectRoot, ...(projectRoot !== previous ? { base: null } : {}) });
updateDraft({
projectRoot,
...(projectRoot !== previous ? { base: null } : {}),
});
}
if (prompt !== undefined) updateComposer(HOME, { text: prompt });
if (useAgents.getState().sessionLocation === "toolWindow") {
@@ -565,7 +677,7 @@ export async function startTask(
providers.find((p) => p.id === draft.provider),
draft,
);
const defaults = bypassDefaults(options);
const defaults = { ...bypassDefaults(options), ...providerDefaults(draft.provider) };
const config = options.flatMap((o) => {
const value =
o.category === "model" || o.category === "thought_level"
@@ -765,7 +877,10 @@ export function addReferences(refs: Array<{ path: string; rel: string; mention:
const current = composerDraft(key);
// A worktree task sees the same files under its own directory.
const root = task?.isolation === "worktree" ? task.cwd : null;
const files: FileRef[] = refs.map((r) => ({ path: root ? `${root}/${r.rel}` : r.path, rel: r.rel }));
const files: FileRef[] = refs.map((r) => ({
path: root ? `${root}/${r.rel}` : r.path,
rel: r.rel,
}));
const mentions = refs.map((r) => r.mention).join(" ");
const text =
current.text && !/\s$/.test(current.text) ? `${current.text} ${mentions} ` : `${current.text}${mentions} `;
+14 -5
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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);
});
});
+31 -1
Переглянути файл
@@ -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: {} },
});
});
});
+1
Переглянути файл
@@ -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
Переглянути файл
@@ -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),
+3 -3
Переглянути файл
@@ -114,10 +114,10 @@ function McpServerEndpoint() {
}
export const MCP_SERVER_PAGE: PageSpec = {
id: "tools.mcpServer",
id: "agents.mcpServer",
title: "MCP Server",
group: "Tools",
order: 604,
group: "Agents",
order: 470,
keywords: ["mcp", "model context protocol", "ai", "agent", "claude", "codex", "tools", "stdio"],
sections: [
{
+77 -5
Переглянути файл
@@ -27,6 +27,24 @@ import { tx } from "@/platform/i18n";
export const TOOLS_GROUP = "Tools";
const SEND_KEY_OPTIONS = [
{ value: "enter", label: "Enter" },
{ value: "modEnter", label: "⌘Enter" },
];
const WHILE_RUNNING_OPTIONS = [
{ value: "queue", label: "Queues the message for after the turn" },
{ value: "steer", label: "Sends it into the turn" },
];
const TOOL_CALL_OPTIONS = [
{ value: "grouped", label: "Grouped by what they did" },
{ value: "each", label: "One per line" },
];
const THINKING_OPTIONS = [
{ value: "auto", label: "Open while it streams" },
{ value: "expanded", label: "Always open" },
{ value: "collapsed", label: "Folded" },
];
const L = WORKFLOW_LIMITS;
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform || navigator.userAgent);
@@ -630,13 +648,67 @@ export const TOOLS_PAGES: PageSpec[] = [
],
},
{
// Under Settings › Tools › Agents (the agents feature registers `tools.agents`).
id: "tools.agents.chat",
id: "agents.chat",
title: "Chat",
group: TOOLS_GROUP,
order: 0,
keywords: ["agent", "claude", "codex", "usage", "quota", "rate limit", "cost", "tokens", "meter"],
group: "Agents",
order: 460,
keywords: [
"agent",
"claude",
"codex",
"usage",
"quota",
"rate limit",
"cost",
"tokens",
"meter",
"send",
"reasoning",
],
sections: [
{
title: "Sending",
options: [
{ kind: "select", key: "agents.sendWith", label: "Send messages with", options: SEND_KEY_OPTIONS },
{
kind: "select",
key: "agents.whileRunning",
label: "While the agent works, sending",
options: WHILE_RUNNING_OPTIONS,
hint: "The other way is a modifier away: ⌘↵ with Enter, ⌘⇧↵ with ⌘↵",
},
],
},
{
title: "Conversation",
options: [
{
kind: "toggle",
key: "agents.foldTurns",
label: "Fold a finished turn's work",
hint: "Reasoning and tool calls before the reply fold into one line under its message",
},
{
kind: "select",
key: "agents.toolCalls",
label: "Tool calls",
options: TOOL_CALL_OPTIONS,
},
{
kind: "select",
key: "agents.thinking",
label: "Reasoning",
options: THINKING_OPTIONS,
},
{ kind: "toggle", key: "agents.expandToolOutput", label: "Show tool output without opening it" },
{
kind: "toggle",
key: "agents.smoothText",
label: "Reveal streaming text smoothly",
hint: "Text appears at a steady pace instead of in bursts",
},
],
},
{
title: "Shown in the Task Header",
options: [
+8
Переглянути файл
@@ -122,6 +122,14 @@ describe("mermaid in the markdown preview", () => {
expect(ids.size).toBe(2);
});
it("leaves a diagram it already drew in place on the next pass", async () => {
const root = preview(SEQ);
await renderMermaidIn(root, []).done;
const svg = root.querySelector("svg");
expect(renderMermaidIn(root, []).async).toBe(false);
expect(root.querySelector("svg")).toBe(svg);
});
it("applies a cached diagram with fresh SVG ids so copies don't share markers", async () => {
const first = preview(SEQ);
await renderMermaidIn(first, []).done;
+12 -3
Переглянути файл
@@ -21,6 +21,8 @@ let seq = 0;
/** Results by theme + source, so edits elsewhere in the file don't re-render diagrams. */
const cache = new Map<string, Result>();
const CACHE_LIMIT = 64;
/** What each placeholder shows (theme + source): a later pass leaves those it already drew alone. */
const drawn = new WeakMap<HTMLElement, string>();
/** mermaid's render isn't re-entrant across configuration changes: one at a time. */
let queue: Promise<unknown> = Promise.resolve();
@@ -327,17 +329,23 @@ export function renderMermaidIn(root: HTMLElement, previous: Node[] = []): Merma
blocks.forEach((block, i) => {
const source = (block.querySelector("code")?.textContent ?? "").replace(/\n+$/, "");
const k = `${key}\n${source}`;
const cached = cache.get(k);
const keep = () => {
const out = block.querySelector(`:scope > .${OUTPUT_CLASS}`);
if (out && block.getAttribute(RENDERED_ATTR) === "ok")
previous[i] = out.querySelector("svg") ?? out.firstChild ?? out;
};
if (cached) {
show(block, cached);
if (drawn.get(block) === k && block.querySelector(`:scope > .${OUTPUT_CLASS}`)) {
keep();
return;
}
const cached = cache.get(k);
if (cached) {
show(block, cached);
drawn.set(block, k);
keep();
return;
}
drawn.delete(block);
show(block, null, previous[i]);
pending.push(
enqueue(source, key, () => live).then((r) => {
@@ -345,6 +353,7 @@ export function renderMermaidIn(root: HTMLElement, previous: Node[] = []): Merma
remember(k, r);
if (!live) return;
show(block, r);
drawn.set(block, k);
keep();
}),
);
+2
Переглянути файл
@@ -256,6 +256,8 @@ describe("catalogs", () => {
/** Product names intentionally kept as written in every locale. */
const SAME_AS_ENGLISH = new Set([
"Docker",
"⌘Enter",
"Fork",
"Shell",
"Java",
"Python",
+61 -2
Переглянути файл
@@ -225,7 +225,6 @@
"Couldn't copy the reply": "Couldn't copy the reply",
"Quote Reply": "Quote Reply",
"Regenerate Response": "Regenerate Response",
"Branch Conversation from Here": "Branch Conversation from Here",
"Couldn't regenerate the response": "Couldn't regenerate the response",
"Reasoning": "Reasoning",
"Branch '{name}'": "Branch '{name}'",
@@ -806,5 +805,65 @@
"host is not remembered for this plugin": "host is not remembered for this plugin",
"URL must name one exact host": "URL must name one exact host",
"Allow for this session": "Allow for this session",
"This allows {host} until the remote session ends.": "This allows {host} until the remote session ends."
"This allows {host} until the remote session ends.": "This allows {host} until the remote session ends.",
"1 message": "1 message",
"{count} messages": "{count} messages",
"Edited 1 file": "Edited 1 file",
"Edited {count} files": "Edited {count} files",
"Ran 1 command": "Ran 1 command",
"Ran {count} commands": "Ran {count} commands",
"Read 1 file": "Read 1 file",
"Read {count} files": "Read {count} files",
"Searched once": "Searched once",
"Searched {count} times": "Searched {count} times",
"Fetched 1 page": "Fetched 1 page",
"Fetched {count} pages": "Fetched {count} pages",
"1 subagent": "1 subagent",
"{count} subagents": "{count} subagents",
"1 other tool call": "1 other tool call",
"{count} other tool calls": "{count} other tool calls",
"Plan {done}/{length}": "Plan {done}/{length}",
"1 changed file": "1 changed file",
"{count} changed files": "{count} changed files",
"Send into the agent's turn": "Send into the agent's turn",
"Queue a message for when the agent is done (⌘⇧↵ sends it now)": "Queue a message for when the agent is done (⌘⇧↵ sends it now)",
"Queue a message for when the agent is done": "Queue a message for when the agent is done",
"Remove {name} from this computer? Its tasks stay, but can't continue until it is installed again.": "Remove {name} from this computer? Its tasks stay, but can't continue until it is installed again.",
"ACP Registry · {version}": "ACP Registry · {version}",
"Agent file": "Agent file",
"Launch": "Launch",
"This agent is installed from the ACP Registry, which launches it.": "This agent is installed from the ACP Registry, which launches it.",
"{version} is available": "{version} is available",
"The agent's models and modes can be chosen here after its first session.": "The agent's models and modes can be chosen here after its first session.",
"Agent's default ({fallback})": "Agent's default ({fallback})",
"Agent's default": "Agent's default",
"What you pick on the New Task page wins; these apply until then and after switching agents.": "What you pick on the New Task page wins; these apply until then and after switching agents.",
"Queues the message for after the turn": "Queues the message for after the turn",
"Sends it into the turn": "Sends it into the turn",
"Grouped by what they did": "Grouped by what they did",
"One per line": "One per line",
"Open while it streams": "Open while it streams",
"Always open": "Always open",
"Folded": "Folded",
"Sending": "Sending",
"Send messages with": "Send messages with",
"While the agent works, sending": "While the agent works, sending",
"The other way is a modifier away: ⌘↵ with Enter, ⌘⇧↵ with ⌘↵": "The other way is a modifier away: ⌘↵ with Enter, ⌘⇧↵ with ⌘↵",
"Conversation": "Conversation",
"Fold a finished turn's work": "Fold a finished turn's work",
"Reasoning and tool calls before the reply fold into one line under its message": "Reasoning and tool calls before the reply fold into one line under its message",
"Tool calls": "Tool calls",
"Show tool output without opening it": "Show tool output without opening it",
"Reveal streaming text smoothly": "Reveal streaming text smoothly",
"Text appears at a steady pace instead of in bursts": "Text appears at a steady pace instead of in bursts",
"⌘Enter": "⌘Enter",
"No checksum provided: the download can't be verified.": "No checksum provided: the download can't be verified.",
"Sign In": "Sign In",
"New Tasks": "New Tasks",
"Coding Agents": "Coding Agents",
"Install coding agents and configure how they launch, chat and connect to the IDE.": "Install coding agents and configure how they launch, chat and connect to the IDE.",
"Fork Agent Task": "Fork Agent Task",
"Couldn't fork the task": "Couldn't fork the task",
"Fork": "Fork",
"Fork Conversation from Here": "Fork Conversation from Here"
}
-17
Переглянути файл
@@ -22,15 +22,10 @@
"Go to original code at line {line}": "Ir al código original en la línea {line}",
"Edited within moved code": "Editado dentro del código movido",
"The language server JDK ({path}) must be a full JDK with jdk.compiler, jdk.jdi and jdk.unsupported for Java language support and debugging. jdtls needs JDK {version} or newer. Download JDK {version}, choose a full JDK in {setting}, or set it to Auto.": "El JDK del servidor de lenguaje ({path}) debe ser un JDK completo con jdk.compiler, jdk.jdi y jdk.unsupported para la compatibilidad con el lenguaje Java y la depuración. jdtls requiere JDK {version} o superior. Descarga JDK {version}, elige un JDK completo en {setting} o establécelo en Automático.",
"ACP Registry": "Registro ACP",
"Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.": "El clangd de Apple no ejecuta clang-tidy, por lo que las comprobaciones de .clang-tidy no se muestran. Instala clangd (LLVM) para obtenerlas.",
"Auto-detect ({defaultProgram})": "Detectar automáticamente ({defaultProgram})",
"Built-in": "Integrado",
"Built-in agents": "Agentes integrados",
"Custom executable": "Ejecutable personalizado",
"Found on PATH": "Encontrado en PATH",
"Launch command": "Comando de inicio",
"Sign in": "Iniciar sesión",
"Leave empty to find the executable on PATH. Changes apply to new sessions.": "Déjalo vacío para buscar el ejecutable en PATH. Los cambios se aplican a las sesiones nuevas.",
"Newer dependency version available": "Versión más reciente de la dependencia disponible",
"Not found": "No encontrado",
@@ -50,7 +45,6 @@
"Run `vibe` in a terminal and sign in.": "Ejecuta `vibe` en una terminal e inicia sesión.",
"Sign in with the agent's own command line first.": "Inicia sesión primero con la línea de comandos del propio agente.",
"This agent is configured in its agent file.": "Este agente se configura en su archivo de agente.",
"This agent is installed from the ACP Registry. Manage its installation in the ACP Registry tab.": "Este agente se instaló desde el Registro ACP. Administra su instalación en la pestaña Registro ACP.",
"This agent runs through a package runner (npm or uv) and has no executable to choose.": "Este agente se ejecuta mediante un ejecutor de paquetes (npm o uv) y no tiene un ejecutable para elegir.",
"Update to {latest}": "Actualizar a {latest}",
"A newer version is available: {latest}": "Hay una versión más reciente disponible: {latest}",
@@ -1686,7 +1680,6 @@
"oxfmt formatting timed out": "Se agotó el tiempo de espera al formatear con oxfmt",
"{name} does not support selection formatting for this language; formatted the whole file": "{name} no admite el formato de selección para este lenguaje; se formateó todo el archivo",
"Formatting timed out": "Se agotó el tiempo de espera de formato",
"{name} executable": "Ejecutable de {name}",
"{name}: Node.js is required": "{name}: se requiere Node.js",
"Formatter": "Formateador",
"JSON with Comments": "JSON con comentarios",
@@ -3468,7 +3461,6 @@
"Ready to Review": "Listo para revisar",
"Archived": "Archivadas",
"Archive": "Archivar",
"Copy Agent Task to a New Task": "Copiar tarea de agente en una nueva tarea",
"Delete Agent Task": "Eliminar tarea de agente",
"Also remove its worktree folder": "También quitar su carpeta de árbol de trabajo",
"needs input": "requiere respuesta",
@@ -4897,26 +4889,19 @@
"More agents…": "Más agentes…",
"Opens Settings › Build, Execution, Deployment › Maven": "Abre Configuración › Compilación, ejecución, despliegue › Maven",
"Opens Settings › Tools › Agents: installs, updates or removes agents from the ACP Registry.": "Abre Configuración › Herramientas › Agentes: instala, actualiza o elimina agentes del Registro ACP.",
"Search the ACP Registry": "Buscar en el Registro ACP",
"Search agents": "Buscar agentes",
"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.": "Los agentes en el Registro ACP son software de terceros. Se ejecutan en esta computadora con sus permisos y pueden leer y modificar sus archivos. Instale únicamente agentes en los que confíe.",
"Cannot load the ACP Registry: {error}": "No se puede cargar el Registro ACP: {error}",
"Loading the ACP Registry…": "Cargando el Registro ACP…",
"No agents match": "Ningún agente coincide",
"The registry has no agents for this system": "El registro no tiene agentes para este sistema",
"{version} available": "{version} disponible",
"Installing {name} {version}…": "Instalando {name} {version}…",
"Website": "Sitio web",
"Removing…": "Eliminando…",
"Registry agents": "Agentes del registro",
"No checksum provided": "No se proporcionó suma de comprobación",
"{name} provides no checksum for its download, so the downloaded file cannot be verified. Install it anyway?": "{name} no proporciona suma de comprobación para su descarga, por lo que no se puede verificar el archivo descargado. ¿Desea instalarlo de todos modos?",
"Usage limit": "Límite de uso",
"{provider} didn't start: {error}": "{provider} no se inició: {error}",
"resets {resetsMs}": "se restablece en {resetsMs}",
"Copy to a New Task": "Copiar a una nueva tarea",
"Couldn't copy the task": "No se pudo copiar la tarea",
"Copy to a New Task From Here": "Copiar a una nueva tarea desde aquí",
"Run Again From Here": "Volver a ejecutar desde aquí",
"The messages after this one are replaced. Files the agent changed stay as they are.": "Los mensajes posteriores a este se reemplazan. Los archivos que el agente modificó permanecen tal como están.",
"Cancelling…": "Cancelando…",
@@ -5072,7 +5057,6 @@
"Saved git credentials": "Credenciales de git guardadas",
"Forget": "Olvidar",
"Remember for this host": "Recordar para este host",
"Unarchive Task": "Desarchivar tarea",
"This task is archived. Unarchive it to continue the conversation.": "Esta tarea está archivada. Desarchívela para continuar la conversación.",
"Unarchive": "Desarchivar",
"Unarchive Agent Tasks": "Desarchivar tareas del agente",
@@ -5577,7 +5561,6 @@
"expected a relative comparison entry path": "se esperaba una ruta relativa de entrada de comparación",
"Agent sessions": "Sesiones del agente",
"Back to conversation": "Volver a la conversación",
"Conversation location": "Ubicación de la conversación",
"Chat in Tool Window": "Chat en ventana de herramientas",
"Chat in Editor": "Chat en el editor",
"Agent settings": "Configuración del agente",
-17
Переглянути файл
@@ -3334,7 +3334,6 @@
"Ready to Review": "Prêt pour la revue",
"Archived": "Archivées",
"Archive": "Archiver",
"Copy Agent Task to a New Task": "Copier la tâche d'agent dans une nouvelle tâche",
"Delete Agent Task": "Supprimer la tâche d'agent",
"Also remove its worktree folder": "Supprimer aussi son dossier de worktree",
"needs input": "réponse requise",
@@ -4745,26 +4744,19 @@
"More agents…": "Plus d'agents…",
"Opens Settings › Build, Execution, Deployment › Maven": "Ouvre Paramètres › Build, exécution, déploiement › Maven",
"Opens Settings › Tools › Agents: installs, updates or removes agents from the ACP Registry.": "Ouvre Paramètres › Outils › Agents : installe, met à jour ou supprime des agents du registre ACP.",
"Search the ACP Registry": "Rechercher dans le registre ACP",
"Search agents": "Rechercher des agents",
"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.": "Les agents du registre ACP sont des logiciels tiers. Ils s'exécutent sur cet ordinateur avec vos autorisations et peuvent lire et modifier vos fichiers. N'installez que des agents de confiance.",
"Cannot load the ACP Registry: {error}": "Impossible de charger le registre ACP : {error}",
"Loading the ACP Registry…": "Chargement du registre ACP…",
"No agents match": "Aucun agent correspondant",
"The registry has no agents for this system": "Le registre ne contient aucun agent pour ce système",
"{version} available": "{version} disponible",
"Installing {name} {version}…": "Installation de {name} {version}…",
"Website": "Site web",
"Removing…": "Suppression…",
"Registry agents": "Agents du registre",
"No checksum provided": "Aucune somme de contrôle fournie",
"{name} provides no checksum for its download, so the downloaded file cannot be verified. Install it anyway?": "{name} ne fournit aucune somme de contrôle pour son téléchargement : le fichier téléchargé ne peut donc pas être vérifié. L'installer quand même ?",
"Usage limit": "Limite d'utilisation",
"{provider} didn't start: {error}": "{provider} n'a pas démarré : {error}",
"resets {resetsMs}": "réinitialisation {resetsMs}",
"Copy to a New Task": "Copier dans une nouvelle tâche",
"Couldn't copy the task": "Impossible de copier la tâche",
"Copy to a New Task From Here": "Copier dans une nouvelle tâche à partir d'ici",
"Run Again From Here": "Réexécuter à partir d'ici",
"The messages after this one are replaced. Files the agent changed stay as they are.": "Les messages suivants sont remplacés. Les fichiers modifiés par l'agent restent en l'état.",
"Cancelling…": "Annulation…",
@@ -4897,7 +4889,6 @@
"Saved git credentials": "Identifiants git enregistrés",
"Forget": "Oublier",
"Remember for this host": "Mémoriser pour cet hôte",
"Unarchive Task": "Désarchiver la tâche",
"This task is archived. Unarchive it to continue the conversation.": "Cette tâche est archivée. Désarchivez-la pour poursuivre la conversation.",
"Unarchive": "Désarchiver",
"Unarchive Agent Tasks": "Désarchiver les tâches d'agent",
@@ -5396,7 +5387,6 @@
"expected a relative comparison entry path": "chemin d'entrée de comparaison relatif attendu",
"Agent sessions": "Sessions d'agent",
"Back to conversation": "Retour à la conversation",
"Conversation location": "Emplacement de la conversation",
"Chat in Tool Window": "Discussion dans la fenêtre d'outil",
"Chat in Editor": "Discussion dans l'éditeur",
"Agent settings": "Paramètres de l'agent",
@@ -5486,21 +5476,14 @@
"'{expected}' expected": "'{expected}' attendu",
"Unexpected token": "Jeton inattendu",
"Syntax": "Syntaxe",
"Built-in": "Intégrés",
"ACP Registry": "Registre ACP",
"Not found": "Introuvable",
"Built-in agents": "Agents intégrés",
"Custom executable": "Exécutable personnalisé",
"Found on PATH": "Trouvé dans le PATH",
"Not found on PATH": "Introuvable dans le PATH",
"{name} executable": "Exécutable {name}",
"Launch command": "Commande de lancement",
"Auto-detect ({defaultProgram})": "Détection automatique ({defaultProgram})",
"Leave empty to find the executable on PATH. Changes apply to new sessions.": "Laissez vide pour rechercher l'exécutable dans le PATH. Les modifications s'appliquent aux nouvelles sessions.",
"This agent runs through a package runner (npm or uv) and has no executable to choose.": "Cet agent s'exécute via un lanceur de paquets (npm ou uv) et n'a pas d'exécutable à choisir.",
"This agent is installed from the ACP Registry. Manage its installation in the ACP Registry tab.": "Cet agent est installé depuis le registre ACP. Gérez son installation dans l'onglet Registre ACP.",
"This agent is configured in its agent file.": "Cet agent est configuré dans son fichier d'agent.",
"Sign in": "Se connecter",
"What the session has cost so far, as the agent reports it": "Ce que la session a coûté jusqu'ici, tel que l'agent l'indique",
"{name}: {percent}% used": "{name} : {percent} % utilisés",
"{used}, resets in {reset}": "{used}, réinitialisation dans {reset}",
-17
Переглянути файл
@@ -56,15 +56,10 @@
"Go to original code at line {line}": "{line}행의 원본 코드로 이동",
"Edited within moved code": "이동된 코드 내에서 편집됨",
"The language server JDK ({path}) must be a full JDK with jdk.compiler, jdk.jdi and jdk.unsupported for Java language support and debugging. jdtls needs JDK {version} or newer. Download JDK {version}, choose a full JDK in {setting}, or set it to Auto.": "언어 서버 JDK({path})는 Java 언어 지원 및 디버깅을 위해 jdk.compiler, jdk.jdi, jdk.unsupported를 포함한 전체 JDK여야 합니다. jdtls에는 JDK {version} 이상이 필요합니다. JDK {version}을(를) 다운로드하거나, {setting}에서 전체 JDK를 선택하거나, 자동으로 설정하세요.",
"ACP Registry": "ACP 레지스트리",
"Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.": "Apple의 clangd는 clang-tidy를 실행하지 않으므로 .clang-tidy의 검사가 표시되지 않습니다. 이 검사를 사용하려면 clangd(LLVM)를 설치하세요.",
"Auto-detect ({defaultProgram})": "자동 감지({defaultProgram})",
"Built-in": "기본 제공",
"Built-in agents": "기본 제공 에이전트",
"Custom executable": "사용자 지정 실행 파일",
"Found on PATH": "PATH에서 찾음",
"Launch command": "실행 명령",
"Sign in": "로그인",
"Leave empty to find the executable on PATH. Changes apply to new sessions.": "비워 두면 PATH에서 실행 파일을 찾습니다. 변경 사항은 새 세션에 적용됩니다.",
"Newer dependency version available": "새 종속성 버전 사용 가능",
"Not found": "찾을 수 없음",
@@ -86,7 +81,6 @@
"Run `vibe` in a terminal and sign in.": "터미널에서 `vibe`를 실행하고 로그인하세요.",
"Sign in with the agent's own command line first.": "먼저 에이전트 자체 명령줄에서 로그인하세요.",
"This agent is configured in its agent file.": "이 에이전트는 에이전트 파일에서 구성됩니다.",
"This agent is installed from the ACP Registry. Manage its installation in the ACP Registry tab.": "이 에이전트는 ACP 레지스트리에서 설치되었습니다. 설치 관리는 ACP 레지스트리 탭에서 하세요.",
"This agent runs through a package runner (npm or uv) and has no executable to choose.": "이 에이전트는 패키지 러너(npm 또는 uv)를 통해 실행되므로 선택할 실행 파일이 없습니다.",
"Update to {latest}": "{latest}(으)로 업데이트",
"A newer version is available: {latest}": "새 버전을 사용할 수 있습니다: {latest}",
@@ -1742,7 +1736,6 @@
"Ruff configuration directory has no ruff.toml, .ruff.toml, or pyproject.toml with [tool.ruff]: {path}": "Ruff 구성 디렉터리에 ruff.toml, .ruff.toml 또는 [tool.ruff]가 있는 pyproject.toml이 없습니다: {path}",
"{name} does not support selection formatting for this language; formatted the whole file": "{name}은(는) 이 언어의 선택 영역 서식 지정을 지원하지 않아 파일 전체의 서식을 지정했습니다",
"Formatting timed out": "서식 지정 시간 초과",
"{name} executable": "{name} 실행 파일",
"{name}: Node.js is required": "{name}: Node.js가 필요합니다",
"Formatter": "서식 지정 도구",
"JSON with Comments": "주석이 있는 JSON",
@@ -3523,7 +3516,6 @@
"Ready to Review": "검토 준비됨",
"Archived": "보관됨",
"Archive": "아카이브",
"Copy Agent Task to a New Task": "에이전트 작업을 새 작업으로 복사",
"Delete Agent Task": "에이전트 작업 삭제",
"Also remove its worktree folder": "워크트리 폴더도 제거",
"needs input": "입력 필요",
@@ -4975,27 +4967,20 @@
"More agents…": "더 많은 에이전트…",
"Opens Settings › Build, Execution, Deployment › Maven": "설정 › 빌드, 실행, 배포 › Maven을 엽니다",
"Opens Settings › Tools › Agents: installs, updates or removes agents from the ACP Registry.": "설정 › 도구 › 에이전트를 엽니다: ACP Registry에서 에이전트를 설치, 업데이트 또는 제거합니다.",
"Search the ACP Registry": "ACP Registry 검색",
"Search agents": "에이전트 검색",
"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.": "ACP Registry의 에이전트는 서드파티 소프트웨어입니다. 이 컴퓨터에서 사용자 권한으로 실행되며 파일을 읽고 변경할 수 있습니다. 신뢰할 수 있는 에이전트만 설치하세요.",
"Cannot load the ACP Registry: {error}": "ACP Registry를 로드할 수 없습니다: {error}",
"Loading the ACP Registry…": "ACP Registry 로드 중…",
"No agents match": "일치하는 에이전트가 없습니다",
"The registry has no agents for this system": "레지스트리에 이 시스템용 에이전트가 없습니다",
"{version} available": "{version} 사용 가능",
"Installing {name} {version}…": "{name} {version} 설치 중…",
"Installing {name} in the background…": "백그라운드에서 {name} 설치 중…",
"Website": "웹사이트",
"Removing…": "제거 중…",
"Registry agents": "레지스트리 에이전트",
"No checksum provided": "체크섬이 제공되지 않음",
"{name} provides no checksum for its download, so the downloaded file cannot be verified. Install it anyway?": "{name}은(는) 다운로드에 대한 체크섬을 제공하지 않으므로 다운로드한 파일을 확인할 수 없습니다. 그래도 설치하시겠습니까?",
"Usage limit": "사용량 한도",
"{provider} didn't start: {error}": "{provider}이(가) 시작되지 않았습니다: {error}",
"resets {resetsMs}": "{resetsMs}에 재설정",
"Copy to a New Task": "새 작업으로 복사",
"Couldn't copy the task": "작업을 복사할 수 없습니다",
"Copy to a New Task From Here": "여기부터 새 작업으로 복사",
"Run Again From Here": "여기부터 다시 실행",
"The messages after this one are replaced. Files the agent changed stay as they are.": "이 메시지 이후의 메시지가 대체됩니다. 에이전트가 변경한 파일은 그대로 유지됩니다.",
"Cancelling…": "취소 중…",
@@ -5146,7 +5131,6 @@
"Saved git credentials": "저장된 git 자격 증명",
"Forget": "취소",
"Remember for this host": "이 호스트에 대해 기억",
"Unarchive Task": "작업 보관 해제",
"This task is archived. Unarchive it to continue the conversation.": "이 작업은 보관되었습니다. 대화를 계속하려면 보관을 해제하세요.",
"Unarchive": "보관 해제",
"Unarchive Agent Tasks": "에이전트 작업 보관 해제",
@@ -5650,7 +5634,6 @@
"expected a relative comparison entry path": "상대 비교 항목 경로가 필요합니다",
"Agent sessions": "에이전트 세션",
"Back to conversation": "대화로 돌아가기",
"Conversation location": "대화 위치",
"Chat in Tool Window": "도구 창에서 채팅",
"Chat in Editor": "편집기에서 채팅",
"Agent settings": "에이전트 설정",
-17
Переглянути файл
@@ -14,15 +14,10 @@
"Go to original code at line {line}": "Przejdź do oryginalnego kodu w wierszu {line}",
"Edited within moved code": "Edytowano w obrębie przeniesionego kodu",
"The language server JDK ({path}) must be a full JDK with jdk.compiler, jdk.jdi and jdk.unsupported for Java language support and debugging. jdtls needs JDK {version} or newer. Download JDK {version}, choose a full JDK in {setting}, or set it to Auto.": "JDK serwera językowego ({path}) musi być pełnym pakietem JDK zawierającym jdk.compiler, jdk.jdi oraz jdk.unsupported dla obsługi języka Java i debugowania. jdtls wymaga JDK w wersji {version} lub nowszej. Pobierz JDK {version}, wybierz pełne JDK w {setting} lub ustaw opcję Automatycznie.",
"ACP Registry": "Rejestr ACP",
"Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.": "Wersja clangd od Apple nie uruchamia clang-tidy, więc kontrole z .clang-tidy nie są wyświetlane. Zainstaluj clangd (LLVM), aby je uzyskać.",
"Auto-detect ({defaultProgram})": "Wykryj automatycznie ({defaultProgram})",
"Built-in": "Wbudowane",
"Built-in agents": "Wbudowani agenci",
"Custom executable": "Własny plik wykonywalny",
"Found on PATH": "Znaleziono w PATH",
"Launch command": "Polecenie uruchomienia",
"Sign in": "Zaloguj się",
"Leave empty to find the executable on PATH. Changes apply to new sessions.": "Pozostaw puste, aby znaleźć plik wykonywalny w PATH. Zmiany zostaną zastosowane do nowych sesji.",
"Newer dependency version available": "Dostępna jest nowsza wersja zależności",
"Not found": "Nie znaleziono",
@@ -42,7 +37,6 @@
"Run `vibe` in a terminal and sign in.": "Uruchom `vibe` w terminalu i zaloguj się.",
"Sign in with the agent's own command line first.": "Najpierw zaloguj się za pomocą własnego wiersza poleceń agenta.",
"This agent is configured in its agent file.": "Ten agent jest skonfigurowany w swoim pliku agenta.",
"This agent is installed from the ACP Registry. Manage its installation in the ACP Registry tab.": "Ten agent został zainstalowany z Rejestru ACP. Zarządzaj jego instalacją w karcie Rejestr ACP.",
"This agent runs through a package runner (npm or uv) and has no executable to choose.": "Ten agent uruchamia się poprzez narzędzie do uruchamiania pakietów (npm lub uv) i nie posiada pliku wykonywalnego do wyboru.",
"Update to {latest}": "Zaktualizuj do {latest}",
"A newer version is available: {latest}": "Dostępna jest nowsza wersja: {latest}",
@@ -1676,7 +1670,6 @@
"oxfmt formatting timed out": "Przekroczono czas oczekiwania na formatowanie oxfmt",
"{name} does not support selection formatting for this language; formatted the whole file": "{name} nie obsługuje formatowania zaznaczenia dla tego języka; przeformatowano cały plik",
"Formatting timed out": "Przekroczono czas oczekiwania na formatowanie",
"{name} executable": "Plik wykonywalny {name}",
"{name}: Node.js is required": "{name}: wymagane jest środowisko Node.js",
"Formatter": "Formatowanie",
"JSON with Comments": "JSON z komentarzami",
@@ -3440,7 +3433,6 @@
"Ready to Review": "Gotowe do przeglądu",
"Archived": "Zarchiwizowane",
"Archive": "Zarchiwizuj",
"Copy Agent Task to a New Task": "Kopiuj zadanie agenta do nowego zadania",
"Delete Agent Task": "Usuń zadanie agenta",
"Also remove its worktree folder": "Usuń również jego folder worktree",
"needs input": "wymaga danych wejściowych",
@@ -4848,26 +4840,19 @@
"More agents…": "Więcej agentów…",
"Opens Settings › Build, Execution, Deployment › Maven": "Otwiera Ustawienia › Budowanie, wykonanie, wdrożenie › Maven",
"Opens Settings › Tools › Agents: installs, updates or removes agents from the ACP Registry.": "Otwiera Ustawienia › Narzędzia › Agenci: instaluje, aktualizuje lub usuwa agentów z rejestru ACP Registry.",
"Search the ACP Registry": "Przeszukaj rejestr ACP Registry",
"Search agents": "Szukaj agentów",
"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.": "Agenci w rejestrze ACP Registry są oprogramowaniem firm trzecich. Działają na tym komputerze z Twoimi uprawnieniami i mogą odczytywać oraz modyfikować Twoje pliki. Instaluj wyłącznie zaufanych agentów.",
"Cannot load the ACP Registry: {error}": "Nie można wczytać rejestru ACP Registry: {error}",
"Loading the ACP Registry…": "Wczytywanie rejestru ACP Registry…",
"No agents match": "Brak pasujących agentów",
"The registry has no agents for this system": "Rejestr nie zawiera agentów dla tego systemu",
"{version} available": "Dostępna wersja {version}",
"Installing {name} {version}…": "Instalowanie {name} {version}…",
"Website": "Strona internetowa",
"Removing…": "Usuwanie…",
"Registry agents": "Agenci z rejestru",
"No checksum provided": "Brak sumy kontrolnej",
"{name} provides no checksum for its download, so the downloaded file cannot be verified. Install it anyway?": "{name} nie dostarcza sumy kontrolnej pobieranego pliku, więc pobrany plik nie może zostać skontrolowany. Zainstalować mimo to?",
"Usage limit": "Limit użycia",
"{provider} didn't start: {error}": "Dostawca {provider} nie uruchomił się: {error}",
"resets {resetsMs}": "resetuje się za {resetsMs}",
"Copy to a New Task": "Kopiuj do nowego zadania",
"Couldn't copy the task": "Nie udało się skopiować zadania",
"Copy to a New Task From Here": "Kopiuj do nowego zadania od tego miejsca",
"Run Again From Here": "Uruchom ponownie od tego miejsca",
"The messages after this one are replaced. Files the agent changed stay as they are.": "Wiadomości po tej zostaną zastąpione. Pliki zmienione przez agenta pozostają bez zmian.",
"Cancelling…": "Anulowanie…",
@@ -5023,7 +5008,6 @@
"Saved git credentials": "Zapisane poświadczenia Git",
"Forget": "Zapomnij",
"Remember for this host": "Zapamiętaj dla tego hosta",
"Unarchive Task": "Przywróć zadanie z archiwum",
"This task is archived. Unarchive it to continue the conversation.": "To zadanie zostało zarchiwizowane. Przywróć je z archiwum, aby kontynuować rozmowę.",
"Unarchive": "Przywróć z archiwum",
"Unarchive Agent Tasks": "Przywróć zadania agenta z archiwum",
@@ -5528,7 +5512,6 @@
"expected a relative comparison entry path": "oczekiwano względnej ścieżki wpisu porównania",
"Agent sessions": "Sesje agenta",
"Back to conversation": "Powrót do rozmowy",
"Conversation location": "Lokalizacja rozmowy",
"Chat in Tool Window": "Czat w oknie narzędziowym",
"Chat in Editor": "Czat w edytorze",
"Agent settings": "Ustawienia agenta",
-17
Переглянути файл
@@ -14,15 +14,10 @@
"Go to original code at line {line}": "Ir para o código original na linha {line}",
"Edited within moved code": "Editado dentro do código movido",
"The language server JDK ({path}) must be a full JDK with jdk.compiler, jdk.jdi and jdk.unsupported for Java language support and debugging. jdtls needs JDK {version} or newer. Download JDK {version}, choose a full JDK in {setting}, or set it to Auto.": "O JDK do servidor de linguagem ({path}) deve ser um JDK completo com jdk.compiler, jdk.jdi e jdk.unsupported para suporte à linguagem Java e depuração. O jdtls requer JDK {version} ou mais recente. Baixe o JDK {version}, escolha um JDK completo em {setting} ou defina como Automático.",
"ACP Registry": "Registro ACP",
"Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.": "O clangd da Apple não executa o clang-tidy, portanto as verificações do .clang-tidy não são exibidas. Instale o clangd (LLVM) para obtê-las.",
"Auto-detect ({defaultProgram})": "Detectar automaticamente ({defaultProgram})",
"Built-in": "Integrado",
"Built-in agents": "Agentes integrados",
"Custom executable": "Executável personalizado",
"Found on PATH": "Encontrado no PATH",
"Launch command": "Comando de inicialização",
"Sign in": "Entrar",
"Leave empty to find the executable on PATH. Changes apply to new sessions.": "Deixe em branco para encontrar o executável no PATH. As alterações se aplicam a novas sessões.",
"Newer dependency version available": "Versão mais recente da dependência disponível",
"Not found": "Não encontrado",
@@ -42,7 +37,6 @@
"Run `vibe` in a terminal and sign in.": "Execute `vibe` em um terminal e entre.",
"Sign in with the agent's own command line first.": "Primeiro entre usando a linha de comando do próprio agente.",
"This agent is configured in its agent file.": "Este agente é configurado em seu arquivo de agente.",
"This agent is installed from the ACP Registry. Manage its installation in the ACP Registry tab.": "Este agente foi instalado a partir do Registro ACP. Gerencie sua instalação na guia Registro ACP.",
"This agent runs through a package runner (npm or uv) and has no executable to choose.": "Este agente é executado por meio de um executor de pacotes (npm ou uv) e não possui um executável para selecionar.",
"Update to {latest}": "Atualizar para {latest}",
"A newer version is available: {latest}": "Uma versão mais recente está disponível: {latest}",
@@ -1676,7 +1670,6 @@
"oxfmt formatting timed out": "Tempo limite de formatação com oxfmt esgotado",
"{name} does not support selection formatting for this language; formatted the whole file": "{name} não oferece suporte à formatação de seleção para esta linguagem; o arquivo inteiro foi formatado",
"Formatting timed out": "Tempo limite de formatação esgotado",
"{name} executable": "Executável do {name}",
"{name}: Node.js is required": "{name}: o Node.js é necessário",
"Formatter": "Formatador",
"JSON with Comments": "JSON com comentários",
@@ -3441,7 +3434,6 @@
"Ready to Review": "Pronto para revisão",
"Archived": "Arquivadas",
"Archive": "Arquivar",
"Copy Agent Task to a New Task": "Copiar tarefa do agente para uma nova tarefa",
"Delete Agent Task": "Excluir tarefa do agente",
"Also remove its worktree folder": "Também remover sua pasta de worktree",
"needs input": "requer entrada",
@@ -4862,26 +4854,19 @@
"More agents…": "Mais agentes…",
"Opens Settings › Build, Execution, Deployment › Maven": "Abre Configurações › Build, Execução, Implantação › Maven",
"Opens Settings › Tools › Agents: installs, updates or removes agents from the ACP Registry.": "Abre Configurações › Ferramentas › Agentes: instala, atualiza ou remove agentes do Registro ACP.",
"Search the ACP Registry": "Pesquisar no Registro ACP",
"Search agents": "Pesquisar agentes",
"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.": "Os agentes no Registro ACP são softwares de terceiros. Eles são executados neste computador com suas permissões e podem ler e alterar seus arquivos. Instale apenas agentes em que você confia.",
"Cannot load the ACP Registry: {error}": "Não é possível carregar o Registro ACP: {error}",
"Loading the ACP Registry…": "Carregando o Registro ACP…",
"No agents match": "Nenhum agente correspondente",
"The registry has no agents for this system": "O registro não possui agentes para este sistema",
"{version} available": "{version} disponível",
"Installing {name} {version}…": "Instalando {name} {version}…",
"Website": "Site",
"Removing…": "Removendo…",
"Registry agents": "Agentes do registro",
"No checksum provided": "Nenhuma soma de verificação fornecida",
"{name} provides no checksum for its download, so the downloaded file cannot be verified. Install it anyway?": "{name} não fornece nenhuma soma de verificação para seu download, portanto o arquivo baixado não pode ser verificado. Instalar mesmo assim?",
"Usage limit": "Limite de uso",
"{provider} didn't start: {error}": "{provider} não iniciou: {error}",
"resets {resetsMs}": "reinicia em {resetsMs}",
"Copy to a New Task": "Copiar para uma nova tarefa",
"Couldn't copy the task": "Não foi possível copiar a tarefa",
"Copy to a New Task From Here": "Copiar para uma nova tarefa a partir daqui",
"Run Again From Here": "Executar novamente a partir daqui",
"The messages after this one are replaced. Files the agent changed stay as they are.": "As mensagens após esta são substituídas. Os arquivos que o agente alterou permanecem como estão.",
"Cancelling…": "Cancelando…",
@@ -5037,7 +5022,6 @@
"Saved git credentials": "Credenciais do git salvas",
"Forget": "Esquecer",
"Remember for this host": "Lembrar para este host",
"Unarchive Task": "Desarquivar tarefa",
"This task is archived. Unarchive it to continue the conversation.": "Esta tarefa está arquivada. Desarquive-a para continuar a conversa.",
"Unarchive": "Desarquivar",
"Unarchive Agent Tasks": "Desarquivar tarefas do agente",
@@ -5542,7 +5526,6 @@
"expected a relative comparison entry path": "esperava-se um caminho relativo de entrada de comparação",
"Agent sessions": "Sessões do agente",
"Back to conversation": "Voltar para a conversa",
"Conversation location": "Local da conversa",
"Chat in Tool Window": "Chat na janela de ferramentas",
"Chat in Editor": "Chat no editor",
"Agent settings": "Configurações do agente",
+61 -19
Переглянути файл
@@ -179,15 +179,10 @@
"Go to original code at line {line}": "跳转到第 {line} 行的原始代码",
"Edited within moved code": "移动代码中的修改",
"The language server JDK ({path}) must be a full JDK with jdk.compiler, jdk.jdi and jdk.unsupported for Java language support and debugging. jdtls needs JDK {version} or newer. Download JDK {version}, choose a full JDK in {setting}, or set it to Auto.": "语言服务器 JDK({path})必须是包含 jdk.compiler、jdk.jdi 和 jdk.unsupported 的完整 JDK,才能支持 Java 语言功能和调试。jdtls 需要 JDK {version} 或更高版本。请下载 JDK {version},在 {setting} 中选择完整 JDK,或设为自动。",
"ACP Registry": "ACP 注册表",
"Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.": "Apple 的 clangd 不运行 clang-tidy,因此不会显示 .clang-tidy 中配置的检查。请安装 clangd(LLVM)以启用这些检查。",
"Auto-detect ({defaultProgram})": "自动检测({defaultProgram})",
"Built-in": "内置",
"Built-in agents": "内置智能体",
"Custom executable": "自定义可执行文件",
"Found on PATH": "已在 PATH 中找到",
"Launch command": "启动命令",
"Sign in": "登录",
"Leave empty to find the executable on PATH. Changes apply to new sessions.": "留空以从 PATH 查找可执行文件。更改对新会话生效。",
"Newer dependency version available": "有更新的依赖版本可用",
"Not found": "未找到",
@@ -209,7 +204,6 @@
"Run `vibe` in a terminal and sign in.": "在终端中运行 `vibe` 并登录。",
"Sign in with the agent's own command line first.": "请先使用智能体自身的命令行登录。",
"This agent is configured in its agent file.": "此智能体在其智能体文件中配置。",
"This agent is installed from the ACP Registry. Manage its installation in the ACP Registry tab.": "此智能体从 ACP 注册表安装。请在 ACP 注册表标签页管理其安装。",
"This agent runs through a package runner (npm or uv) and has no executable to choose.": "此智能体通过包运行器(npm 或 uv)启动,无需选择可执行文件。",
"Update to {latest}": "更新至 {latest}",
"A newer version is available: {latest}": "有新版本可用:{latest}",
@@ -2003,7 +1997,6 @@
"Ruff configuration directory has no ruff.toml, .ruff.toml, or pyproject.toml with [tool.ruff]: {path}": "Ruff 配置目录中没有 ruff.toml、.ruff.toml 或包含 [tool.ruff] 的 pyproject.toml:{path}",
"{name} does not support selection formatting for this language; formatted the whole file": "{name} 不支持此语言的选区格式化,已格式化整个文件",
"Formatting timed out": "格式化超时",
"{name} executable": "{name} 可执行文件",
"{name}: Node.js is required": "{name}:需要 Node.js",
"Formatter": "格式化工具",
"JSON with Comments": "带注释的 JSON",
@@ -3814,7 +3807,6 @@
"Ready to Review": "待审阅",
"Archived": "已归档",
"Archive": "归档",
"Copy Agent Task to a New Task": "将 Agent 任务复制为新任务",
"Delete Agent Task": "删除 Agent 任务",
"Also remove its worktree folder": "同时删除它的 worktree 目录",
"needs input": "等待输入",
@@ -5287,27 +5279,20 @@
"More agents…": "更多 Agent…",
"Opens Settings › Build, Execution, Deployment › Maven": "打开 设置 › 构建、执行、部署 › Maven。",
"Opens Settings › Tools › Agents: installs, updates or removes agents from the ACP Registry.": "打开 设置 › 工具 › Agents:从 ACP Registry 安装、更新或移除 Agent。",
"Search the ACP Registry": "搜索 ACP Registry",
"Search agents": "搜索 Agent",
"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.": "ACP Registry 中的 Agent 是第三方软件。它们以你的权限在本机运行,可以读取和修改你的文件。请只安装你信任的 Agent。",
"Cannot load the ACP Registry: {error}": "无法加载 ACP Registry:{error}",
"Loading the ACP Registry…": "正在加载 ACP Registry…",
"No agents match": "没有匹配的 Agent",
"The registry has no agents for this system": "Registry 中没有适用于此系统的 Agent",
"{version} available": "{version} 可用",
"Installing {name} {version}…": "正在安装 {name} {version}…",
"Installing {name} in the background…": "正在后台安装 {name}…",
"Website": "网站",
"Removing…": "正在移除…",
"Registry agents": "Registry Agent",
"No checksum provided": "未提供校验值",
"{name} provides no checksum for its download, so the downloaded file cannot be verified. Install it anyway?": "{name} 未为其下载文件提供校验值,无法校验下载的文件。仍要安装吗?",
"Usage limit": "用量已达上限",
"{provider} didn't start: {error}": "{provider} 未能启动:{error}",
"resets {resetsMs}": "{resetsMs} 重置",
"Copy to a New Task": "复制为新任务",
"Couldn't copy the task": "无法复制任务",
"Copy to a New Task From Here": "从此处复制为新任务",
"Run Again From Here": "从此处重新运行",
"The messages after this one are replaced. Files the agent changed stay as they are.": "这条之后的消息会被替换。Agent 改动过的文件保持不变。",
"Cancelling…": "正在取消…",
@@ -5463,7 +5448,6 @@
"Saved git credentials": "已保存的 git 凭据",
"Forget": "忘记",
"Remember for this host": "记住此主机",
"Unarchive Task": "取消归档任务",
"This task is archived. Unarchive it to continue the conversation.": "此任务已归档。取消归档后即可继续对话。",
"Unarchive": "取消归档",
"Unarchive Agent Tasks": "取消归档 Agent 任务",
@@ -5970,7 +5954,6 @@
"expected a relative comparison entry path": "需要比较条目的相对路径",
"Agent sessions": "智能体会话",
"Back to conversation": "返回会话",
"Conversation location": "会话显示位置",
"Chat in Tool Window": "在工具窗口中聊天",
"Chat in Editor": "在编辑器中聊天",
"Agent settings": "智能体设置",
@@ -6704,7 +6687,6 @@
"Couldn't copy the reply": "无法复制回复",
"Quote Reply": "引用回复",
"Regenerate Response": "重新生成回复",
"Branch Conversation from Here": "从此处创建分支对话",
"Couldn't regenerate the response": "无法重新生成回复",
"Reasoning": "思考过程",
"Cannot copy the value": "无法复制该值",
@@ -7139,5 +7121,65 @@
"host is not remembered for this plugin": "此插件没有记住该主机的授权",
"URL must name one exact host": "URL 必须指定一个确切主机",
"Allow for this session": "本次会话内允许",
"This allows {host} until the remote session ends.": "允许连接到 {host},直到本次远程会话结束。"
"This allows {host} until the remote session ends.": "允许连接到 {host},直到本次远程会话结束。",
"1 message": "1 条消息",
"{count} messages": "{count} 条消息",
"Edited 1 file": "编辑了 1 个文件",
"Edited {count} files": "编辑了 {count} 个文件",
"Ran 1 command": "运行了 1 条命令",
"Ran {count} commands": "运行了 {count} 条命令",
"Read 1 file": "读取了 1 个文件",
"Read {count} files": "读取了 {count} 个文件",
"Searched once": "搜索了 1 次",
"Searched {count} times": "搜索了 {count} 次",
"Fetched 1 page": "获取了 1 个网页",
"Fetched {count} pages": "获取了 {count} 个网页",
"1 subagent": "1 个子代理",
"{count} subagents": "{count} 个子代理",
"1 other tool call": "1 次其他工具调用",
"{count} other tool calls": "{count} 次其他工具调用",
"Plan {done}/{length}": "计划 {done}/{length}",
"1 changed file": "改动 1 个文件",
"{count} changed files": "改动 {count} 个文件",
"Send into the agent's turn": "发送到当前回合",
"Queue a message for when the agent is done (⌘⇧↵ sends it now)": "排队一条消息,agent 做完后发送(⌘⇧↵ 立即发送)",
"Queue a message for when the agent is done": "排队一条消息,agent 做完后发送",
"Remove {name} from this computer? Its tasks stay, but can't continue until it is installed again.": "从这台电脑移除 {name}?它的任务会保留,但在重新安装前无法继续。",
"ACP Registry · {version}": "ACP 注册表 · {version}",
"Agent file": "Agent 文件",
"Launch": "启动",
"This agent is installed from the ACP Registry, which launches it.": "此 agent 从 ACP 注册表安装,由注册表负责启动。",
"{version} is available": "{version} 可用",
"The agent's models and modes can be chosen here after its first session.": "agent 运行过一次会话后,才能在这里选择它的模型和模式。",
"Agent's default ({fallback})": "agent 默认({fallback})",
"Agent's default": "agent 默认",
"What you pick on the New Task page wins; these apply until then and after switching agents.": "以“新任务”页上的选择为准;在那之前,以及切换 agent 之后,使用这里的设置。",
"Queues the message for after the turn": "排队,等本回合结束后发送",
"Sends it into the turn": "立即发送到当前回合",
"Grouped by what they did": "按操作类型合并",
"One per line": "每次调用一行",
"Open while it streams": "生成时展开",
"Always open": "始终展开",
"Folded": "折叠",
"Sending": "发送",
"Send messages with": "发送消息",
"While the agent works, sending": "agent 工作时发送",
"The other way is a modifier away: ⌘↵ with Enter, ⌘⇧↵ with ⌘↵": "另一种方式只差一个修饰键:用 Enter 发送时按 ⌘↵,用 ⌘↵ 发送时按 ⌘⇧↵",
"Conversation": "对话",
"Fold a finished turn's work": "折叠已完成回合的过程",
"Reasoning and tool calls before the reply fold into one line under its message": "回复之前的思考和工具调用折叠成一行,显示在对应消息下方",
"Tool calls": "工具调用",
"Show tool output without opening it": "默认展开工具输出",
"Reveal streaming text smoothly": "平滑显示生成中的文字",
"Text appears at a steady pace instead of in bursts": "文字匀速出现,而不是一段一段地跳出",
"⌘Enter": "⌘Enter",
"No checksum provided: the download can't be verified.": "未提供校验和:无法校验下载的文件。",
"Sign In": "登录",
"New Tasks": "新任务",
"Coding Agents": "编程 Agent",
"Install coding agents and configure how they launch, chat and connect to the IDE.": "安装编程 Agent,并配置其启动方式、聊天行为以及与 IDE 的连接。",
"Fork Agent Task": "Fork Agent 任务",
"Couldn't fork the task": "无法 Fork 任务",
"Fork": "Fork",
"Fork Conversation from Here": "从此处 Fork 对话"
}
+1
Переглянути файл
@@ -68,6 +68,7 @@ const ICONS = {
"file-md": "fileTypes/markdown",
"file-image": "fileTypes/image",
paperclip: "openidea/general/paperclip",
image: "openidea/general/image",
"file-config": "fileTypes/config",
"file-python": "fileTypes/python",
"file-jupyter": "fileTypes/jupyter",
+6
Переглянути файл
@@ -0,0 +1,6 @@
<!-- OpenIDEA: attach an image -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="3.5" width="11" height="9" rx="1.5" stroke="#6C707E"/>
<circle cx="10.25" cy="6.25" r="1.25" stroke="#6C707E"/>
<path d="M2.5 11L6 7.5L10.5 12.5" stroke="#6C707E" stroke-linejoin="round"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 344 B

+2
Переглянути файл
@@ -58,6 +58,8 @@ export interface ToolWindowContribution {
* present the header title also gets a "▾" opening just these items.
*/
headerMenu?: MenuItem[] | ((id: string) => MenuItem[]);
/** `headerMenu` stays in "⋮" only: the title gets no "▾". */
plainTitle?: boolean;
/**
* The title as a view switcher (IDEA's "Project ▾" panes): a React hook
* returning the current view's label and the items the title opens instead
+1 -1
Переглянути файл
@@ -709,7 +709,7 @@ function HeaderRow({
const own = useHeaderMenu(tw.id);
const useTitleMenu = tw.titleMenu ?? noTitleMenu;
const titleMenu = useTitleMenu(tw.id);
const titleItems = titleMenu?.items ?? own;
const titleItems = titleMenu?.items ?? (tw.plainTitle ? [] : own);
return (
<header