дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-11 20:25:41 +02:00
Порівняти коміти
24
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
78d0d6f135 | ||
|
|
7902fb58be | ||
|
|
8cef22a83d | ||
|
|
08ba4fcfc5 | ||
|
|
9fd88fc414 | ||
|
|
d08061907f | ||
|
|
e69442217c | ||
|
|
919c9d7056 | ||
|
|
f6a3a65691 | ||
|
|
0636ae6550 | ||
|
|
238aa71f1a | ||
|
|
098b296159 | ||
|
|
f6af6f7172 | ||
|
|
d61386d4b9 | ||
|
|
a7e1ad26f3 | ||
|
|
3ffb897f48 | ||
|
|
db1238cdd5 | ||
|
|
401325b8c4 | ||
|
|
b33bb19807 | ||
|
|
36261f0190 | ||
|
|
ca339c4934 | ||
|
|
338dc71d20 | ||
|
|
7b3acb192f | ||
|
|
27c3f5ebaa |
No files matched your search
@@ -345,6 +345,8 @@ struct Task {
|
||||
/// Settings already sent this session (`(option, value)`), so a refusal is not retried.
|
||||
config_sent: Mutex<HashSet<(String, String)>>,
|
||||
dirty: Mutex<BTreeSet<(Session, usize)>>,
|
||||
/// How much of each session's streaming message was last sent, so the next flush sends the rest.
|
||||
streamed: Mutex<HashMap<Session, Sent>>,
|
||||
flush_scheduled: AtomicBool,
|
||||
stderr: Mutex<VecDeque<String>>,
|
||||
/// Set by `delete`; held while saving.
|
||||
@@ -852,6 +854,7 @@ impl AgentManager {
|
||||
s.task.updated_ms = now_ms();
|
||||
first
|
||||
};
|
||||
task.streamed.lock().clear();
|
||||
task.persist();
|
||||
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: 0 });
|
||||
task.emit_task();
|
||||
@@ -877,6 +880,7 @@ impl AgentManager {
|
||||
s.task.updated_ms = now_ms();
|
||||
first
|
||||
};
|
||||
task.streamed.lock().clear();
|
||||
task.persist();
|
||||
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
|
||||
task.emit_task();
|
||||
@@ -1129,6 +1133,7 @@ impl Task {
|
||||
idle_epoch: AtomicU64::new(0),
|
||||
config_sent: Mutex::new(HashSet::new()),
|
||||
dirty: Mutex::new(BTreeSet::new()),
|
||||
streamed: Mutex::new(HashMap::new()),
|
||||
flush_scheduled: AtomicBool::new(false),
|
||||
stderr: Mutex::new(VecDeque::new()),
|
||||
deleted: Mutex::new(false),
|
||||
@@ -1250,11 +1255,12 @@ impl Task {
|
||||
let indices = std::mem::take(&mut *self.dirty.lock());
|
||||
let events: Vec<AgentEvent> = {
|
||||
let s = self.state.lock();
|
||||
let mut streamed = self.streamed.lock();
|
||||
indices
|
||||
.into_iter()
|
||||
.filter_map(|(subagent, i)| {
|
||||
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?.clone();
|
||||
Some(AgentEvent::Entry { task: self.id.clone(), subagent, index: i as u32, entry })
|
||||
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?;
|
||||
Some(entry_event(&self.id, &mut streamed, subagent, i, entry))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -2316,3 +2322,77 @@ fn stdio_mcp_servers(executable: Option<&std::path::Path>, supported: bool, root
|
||||
let command = ide_mcp::stdio_command(executable, root);
|
||||
vec![json!({ "name": "OpenIDEA", "command": command[0], "args": command[1..], "env": [] })]
|
||||
}
|
||||
|
||||
/// What was sent of a streaming message: its first `bytes` (`utf16` UTF-16 units), which
|
||||
/// ended with `tail`. A message only grows while it streams, so its tail tells a message
|
||||
/// that went on from one that was replaced without comparing (or keeping) all of it.
|
||||
struct Sent {
|
||||
index: usize,
|
||||
bytes: usize,
|
||||
utf16: usize,
|
||||
tail: String,
|
||||
}
|
||||
|
||||
const SENT_TAIL: usize = 64;
|
||||
|
||||
impl Sent {
|
||||
fn new(index: usize, text: &str) -> Self {
|
||||
Self::after(index, text, 0, 0)
|
||||
}
|
||||
|
||||
/// `text` as sent, of which the first `bytes` (`utf16` units) were sent before.
|
||||
fn after(index: usize, text: &str, bytes: usize, utf16: usize) -> Self {
|
||||
let mut start = text.len().saturating_sub(SENT_TAIL);
|
||||
while !text.is_char_boundary(start) {
|
||||
start -= 1;
|
||||
}
|
||||
let utf16 = utf16 + text[bytes..].encode_utf16().count();
|
||||
Self { index, bytes: text.len(), utf16, tail: text[start..].to_owned() }
|
||||
}
|
||||
|
||||
/// Whether `text` at `index` is this message, gone on.
|
||||
fn extended_by(&self, index: usize, text: &str) -> bool {
|
||||
self.index == index
|
||||
&& text.len() > self.bytes
|
||||
&& text.is_char_boundary(self.bytes)
|
||||
&& text[..self.bytes].ends_with(&self.tail)
|
||||
}
|
||||
}
|
||||
|
||||
/// The event that brings the UI's copy of entry `i` up to date: only the new text when
|
||||
/// it extends the message last sent for the session, else the whole entry.
|
||||
fn entry_event(
|
||||
task: &str,
|
||||
streamed: &mut HashMap<Session, Sent>,
|
||||
subagent: Session,
|
||||
i: usize,
|
||||
entry: &AgentEntry,
|
||||
) -> AgentEvent {
|
||||
let text = match entry {
|
||||
AgentEntry::Agent { text } | AgentEntry::Thought { text } => Some(text),
|
||||
_ => None,
|
||||
};
|
||||
let Some(text) = text else {
|
||||
if streamed.get(&subagent).is_some_and(|sent| sent.index == i) {
|
||||
streamed.remove(&subagent);
|
||||
}
|
||||
return AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() };
|
||||
};
|
||||
match streamed.get(&subagent).filter(|sent| sent.extended_by(i, text)) {
|
||||
Some(sent) => {
|
||||
let (bytes, from) = (sent.bytes, sent.utf16);
|
||||
streamed.insert(subagent.clone(), Sent::after(i, text, bytes, from));
|
||||
AgentEvent::Append {
|
||||
task: task.to_owned(),
|
||||
subagent,
|
||||
index: i as u32,
|
||||
from: from as u32,
|
||||
text: text[bytes..].to_owned(),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
streamed.insert(subagent.clone(), Sent::new(i, text));
|
||||
AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,6 +518,15 @@ pub enum AgentEvent {
|
||||
index: u32,
|
||||
entry: AgentEntry,
|
||||
},
|
||||
/// Text was appended to entry `index` (an agent message or a thought): it read the first
|
||||
/// `from` UTF-16 units of its text when the previous event for it was sent.
|
||||
Append {
|
||||
task: String,
|
||||
subagent: Option<String>,
|
||||
index: u32,
|
||||
from: u32,
|
||||
text: String,
|
||||
},
|
||||
/// An agent reported the usage limits of its account (shared by every task of `provider`).
|
||||
Limits {
|
||||
provider: String,
|
||||
|
||||
@@ -2532,3 +2532,39 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_text_is_sent_as_appends() {
|
||||
let mut streamed = HashMap::new();
|
||||
let agent = |t: &str| AgentEntry::Agent { text: t.into() };
|
||||
let first = entry_event("t", &mut streamed, None, 3, &agent("Hé"));
|
||||
assert!(matches!(first, AgentEvent::Entry { index: 3, .. }), "the first sight of a message is whole");
|
||||
let next = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there"));
|
||||
assert!(
|
||||
matches!(&next, AgentEvent::Append { subagent: None, index: 3, from: 2, text, .. } if text == " 👋 there"),
|
||||
"{next:?}"
|
||||
);
|
||||
let sub = entry_event("t", &mut streamed, Some("s".into()), 3, &agent("Hé 👋 there!"));
|
||||
assert!(matches!(sub, AgentEvent::Entry { .. }), "each session streams on its own");
|
||||
let after = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there."));
|
||||
assert!(
|
||||
matches!(&after, AgentEvent::Append { from: 11, text, .. } if text == "."),
|
||||
"from counts UTF-16 units: {after:?}"
|
||||
);
|
||||
let replaced = entry_event("t", &mut streamed, None, 3, &agent("Other"));
|
||||
assert!(matches!(replaced, AgentEvent::Entry { .. }), "text that doesn't extend the sent text is sent whole");
|
||||
let thought = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm…".into() });
|
||||
assert!(matches!(thought, AgentEvent::Entry { index: 4, .. }), "a new message starts whole");
|
||||
let tool = AgentEntry::Error { message: "x".into() };
|
||||
entry_event("t", &mut streamed, None, 4, &tool);
|
||||
let again = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm… ok".into() });
|
||||
assert!(matches!(again, AgentEvent::Entry { .. }), "an entry replaced by another kind forgets its text");
|
||||
|
||||
let long = "é".repeat(5000);
|
||||
entry_event("t", &mut streamed, None, 5, &agent(&long));
|
||||
assert!(streamed[&None].tail.len() <= 64, "only the end of what was sent is kept");
|
||||
let on = entry_event("t", &mut streamed, None, 5, &agent(&format!("{long}!")));
|
||||
assert!(matches!(&on, AgentEvent::Append { from: 5000, text, .. } if text == "!"), "{on:?}");
|
||||
let rewritten = entry_event("t", &mut streamed, None, 5, &agent(&format!("{}x!??", "é".repeat(4999))));
|
||||
assert!(matches!(rewritten, AgentEvent::Entry { .. }), "a rewritten end is sent whole");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ const entries: AgentEntry[] = [
|
||||
|
||||
for (const theme of ["dark", "light"] as const) {
|
||||
for (const width of [760, 1400]) {
|
||||
test(`approval stays inside the agent column at ${width}px in ${theme}`, async ({ page }) => {
|
||||
test(`approval is asked above the composer at ${width}px in ${theme}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
@@ -85,43 +85,47 @@ for (const theme of ["dark", "light"] as const) {
|
||||
await expect(card.locator("pre")).toHaveText(command);
|
||||
await expect(card.getByRole("button")).toHaveText(["Allow Once", "Always Allow", "Decline", "Decline and Stop"]);
|
||||
const geometry = await card.evaluate((el) => {
|
||||
const transcript = el.parentElement!.parentElement!;
|
||||
// Asked above the composer, below the conversation.
|
||||
const area = el.closest('[class*="_composerArea_"]')!;
|
||||
const transcript = area.parentElement!.querySelector('[class*="_stream_"]')!;
|
||||
const prompt = transcript.querySelector('[class*="_user_"]')!;
|
||||
const reply = transcript.querySelector('[class*="_agentCard_"]')!;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const bounds = transcript.getBoundingClientRect();
|
||||
const bounds = area.getBoundingClientRect();
|
||||
return {
|
||||
scroll: transcript.scrollWidth,
|
||||
client: transcript.clientWidth,
|
||||
scroll: area.scrollWidth,
|
||||
client: area.clientWidth,
|
||||
inStream: transcript.contains(el),
|
||||
contained: rect.left >= bounds.left && rect.right <= bounds.right,
|
||||
below: rect.top >= transcript.getBoundingClientRect().bottom - 1,
|
||||
promptRadius: parseFloat(getComputedStyle(prompt).borderRadius),
|
||||
replyBackground: getComputedStyle(reply).backgroundColor,
|
||||
replyBorder: getComputedStyle(reply).borderTopWidth,
|
||||
userOnRight: prompt.getBoundingClientRect().right > reply.getBoundingClientRect().right,
|
||||
approvalAligned: Math.abs(rect.left - reply.getBoundingClientRect().left) < 1,
|
||||
authorBeforeWork:
|
||||
transcript.querySelector('[class*="_agentAuthor_"]')?.nextElementSibling?.matches("details") ?? false,
|
||||
footerAligned:
|
||||
Math.abs(prompt.getBoundingClientRect().right - prompt.nextElementSibling!.getBoundingClientRect().right) <
|
||||
1,
|
||||
headAligned:
|
||||
Math.abs(
|
||||
prompt.getBoundingClientRect().right - prompt.previousElementSibling!.getBoundingClientRect().right,
|
||||
) < 1,
|
||||
cardWidth: rect.width,
|
||||
available: bounds.width,
|
||||
actionsVisible: getComputedStyle(reply.nextElementSibling!.querySelector('[class*="_messageActions_"]')!)
|
||||
.opacity,
|
||||
replyFooter: reply.nextElementSibling != null,
|
||||
};
|
||||
});
|
||||
expect(geometry.scroll).toBe(geometry.client);
|
||||
expect(geometry.inStream).toBe(false);
|
||||
expect(geometry.contained).toBe(true);
|
||||
expect(geometry.below).toBe(true);
|
||||
expect(geometry.promptRadius).toBeLessThanOrEqual(6);
|
||||
// Replies read as plain text on the panel.
|
||||
expect(geometry.replyBackground).toBe("rgba(0, 0, 0, 0)");
|
||||
expect(geometry.replyBorder).toBe("0px");
|
||||
expect(geometry.userOnRight).toBe(true);
|
||||
expect(geometry.approvalAligned).toBe(true);
|
||||
expect(geometry.authorBeforeWork).toBe(true);
|
||||
expect(geometry.footerAligned).toBe(true);
|
||||
// The last message stays actionable even while an approval follows it.
|
||||
expect(geometry.actionsVisible).toBe("1");
|
||||
expect(geometry.headAligned).toBe(true);
|
||||
// The turn waits on the approval: its interim message has no footer yet.
|
||||
expect(geometry.replyFooter).toBe(false);
|
||||
if (width === 760) expect(geometry.cardWidth).toBeGreaterThan(geometry.available * 0.8);
|
||||
await card.locator("pre").focus();
|
||||
for (const label of ["Allow Once", "Always Allow", "Decline", "Decline and Stop"]) {
|
||||
|
||||
@@ -278,6 +278,8 @@ test("keeps chat history readable and selections free of timestamps and status l
|
||||
expect(selected).not.toMatch(/AM|PM|5s|Reasoning|Requesting task context/);
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(selected);
|
||||
// The finished turn's reasoning is folded with its work.
|
||||
await stream.locator("[class*='_turnFold_'] > summary").click();
|
||||
await stream.locator("[class*='_thought_'] > summary").click();
|
||||
await expect(stream.getByText("Requesting task context", { exact: true })).toBeVisible();
|
||||
await expect(stream.locator("[class*='_thoughtBody_'] strong")).toHaveText("Requesting task context");
|
||||
|
||||
@@ -112,19 +112,23 @@ for (const selected of [false, true]) {
|
||||
});
|
||||
}
|
||||
|
||||
test("opens a folded process with the keyboard and jumps back to the latest reply", async ({ page }) => {
|
||||
test("opens a folded turn with the keyboard and jumps back to the latest reply", async ({ page }) => {
|
||||
const stream = await openHistory(page);
|
||||
await stream.evaluate((el) => {
|
||||
el.scrollTop = 0;
|
||||
});
|
||||
const jump = page.getByRole("button", { name: "Jump to Latest" });
|
||||
await expect(jump).toBeVisible();
|
||||
const fold = stream.locator("[class*='_processRun_']").first();
|
||||
const fold = stream.locator("[class*='_turnFold_']").first();
|
||||
const summary = fold.locator(":scope > summary");
|
||||
await summary.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(fold).toHaveAttribute("open", "");
|
||||
const run = fold.locator("[class*='_processRun_']").first();
|
||||
await expect(run).toBeVisible();
|
||||
await run.locator(":scope > summary").click();
|
||||
await expect(fold.getByText("read src/module-0.ts", { exact: true })).toBeVisible();
|
||||
await summary.focus();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(fold).not.toHaveAttribute("open", "");
|
||||
await jump.focus();
|
||||
|
||||
@@ -5,14 +5,14 @@ for (const [id, selector] of [
|
||||
["editor.inspections", "[class*='split'] > [class*='details']"],
|
||||
["languages.servers", "[class*='body'] > [class*='details']"],
|
||||
["plugins", "[class*='body'] > [class*='details']"],
|
||||
["tools.agents", "section[aria-label='Codex executable']"],
|
||||
["agents.list", "section[aria-label='Codex']"],
|
||||
]) {
|
||||
test(`${id} keeps its details usable beside the list`, async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
const dialog = page.getByRole("dialog", { name: "Settings", exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.evaluate(async (id) => {
|
||||
if (id === "tools.agents") {
|
||||
if (id === "agents.list") {
|
||||
const path = "/src/features/agents/registry.ts";
|
||||
const { tauriCommands } = await import(/* @vite-ignore */ path);
|
||||
tauriCommands.providers = async () => [
|
||||
@@ -26,6 +26,9 @@ for (const [id, selector] of [
|
||||
installed: true,
|
||||
commandOverridden: false,
|
||||
env: {},
|
||||
config: [],
|
||||
commands: [],
|
||||
source: { kind: "builtin" },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Tools › MCP Server: runs `ide_mcp::McpServer` over built-in host tools
|
||||
//! Agents › MCP Server: runs `ide_mcp::McpServer` over built-in host tools
|
||||
//! while `mcpServer.enabled` is on, and reports where it listens.
|
||||
|
||||
use crate::state::AppState;
|
||||
@@ -63,7 +63,7 @@ pub fn stop(state: &AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What Settings › Tools › MCP Server shows.
|
||||
/// What Settings › Agents › MCP Server shows.
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpServerStatus {
|
||||
|
||||
@@ -21,7 +21,7 @@ pub struct AppState {
|
||||
pub plugins: Arc<ide_host::plugins::PluginHost>,
|
||||
/// The built-in web server (Open In › Browser, the editor's browser bar).
|
||||
pub web: ide_web::WebServer,
|
||||
/// Tools › MCP Server: built-in IDE tools for agents.
|
||||
/// Agents › MCP Server: built-in IDE tools for agents.
|
||||
pub mcp: crate::commands::mcp::McpService,
|
||||
/// Credential prompts of `ssh` / `git` child processes (`None` if it failed to start).
|
||||
pub askpass: Option<ide_ssh::askpass::AskpassServer>,
|
||||
|
||||
+79
-10
@@ -1657,6 +1657,11 @@ export type AgentEvent =
|
||||
{ type: "truncated"; task: string; length: number } |
|
||||
/** Entry `index` of `task` (of its subagent `subagent`) was added or replaced. */
|
||||
{ type: "entry"; task: string; subagent: string | null; index: number; entry: AgentEntry } |
|
||||
/**
|
||||
* Text was appended to entry `index` (an agent message or a thought): it read the first
|
||||
* `from` UTF-16 units of its text when the previous event for it was sent.
|
||||
*/
|
||||
{ type: "append"; task: string; subagent: string | null; index: number; from: number; text: string } |
|
||||
/** An agent reported the usage limits of its account (shared by every task of `provider`). */
|
||||
{ type: "limits"; provider: string; limits: AgentLimit[] } |
|
||||
/** The installed or configured agent providers changed. */
|
||||
@@ -1780,8 +1785,11 @@ export type AgentRetry = {
|
||||
error: string | null,
|
||||
};
|
||||
|
||||
/** What the meters in an agent task's header show. */
|
||||
export type AgentSettings = {
|
||||
/** Settings › Agents: what the conversation shows and how messages are sent. */
|
||||
export type AgentSettings = AgentSettings_Serialize | AgentSettings_Deserialize;
|
||||
|
||||
/** Settings › Agents: what the conversation shows and how messages are sent. */
|
||||
export type AgentSettings_Deserialize = {
|
||||
/** Context window use. */
|
||||
showContextUsage?: boolean,
|
||||
/** Time the agent spent on the task. */
|
||||
@@ -1790,6 +1798,50 @@ export type AgentSettings = {
|
||||
showUsageLimits?: boolean,
|
||||
/** What the session has cost so far, as the agent reports it. */
|
||||
showCost?: boolean,
|
||||
/** A finished turn's work folds into one line under its message, leaving the reply. */
|
||||
foldTurns?: boolean,
|
||||
/** Streaming text is revealed at a steady pace rather than chunk by chunk. */
|
||||
smoothText?: boolean,
|
||||
thinking?: ThinkingDisplay,
|
||||
toolCalls?: ToolCallDisplay,
|
||||
/** A tool call's output (a command's, a diff) is shown without opening it. */
|
||||
expandToolOutput?: boolean,
|
||||
sendWith?: SendKey,
|
||||
/**
|
||||
* What sending does while the agent works: queue the message for after the turn, or
|
||||
* send it into the turn.
|
||||
*/
|
||||
whileRunning?: WhileRunning,
|
||||
/** Settings new tasks of an agent start with: agent id → option id → value. */
|
||||
defaults?: { [key in string]: { [key in string]: string } },
|
||||
};
|
||||
|
||||
/** Settings › Agents: what the conversation shows and how messages are sent. */
|
||||
export type AgentSettings_Serialize = {
|
||||
/** Context window use. */
|
||||
showContextUsage: boolean,
|
||||
/** Time the agent spent on the task. */
|
||||
showWorkTime: boolean,
|
||||
/** The account's usage-limit windows (Claude's 5-hour and weekly limits). */
|
||||
showUsageLimits: boolean,
|
||||
/** What the session has cost so far, as the agent reports it. */
|
||||
showCost: boolean,
|
||||
/** A finished turn's work folds into one line under its message, leaving the reply. */
|
||||
foldTurns: boolean,
|
||||
/** Streaming text is revealed at a steady pace rather than chunk by chunk. */
|
||||
smoothText: boolean,
|
||||
thinking: ThinkingDisplay,
|
||||
toolCalls: ToolCallDisplay,
|
||||
/** A tool call's output (a command's, a diff) is shown without opening it. */
|
||||
expandToolOutput: boolean,
|
||||
sendWith: SendKey,
|
||||
/**
|
||||
* What sending does while the agent works: queue the message for after the turn, or
|
||||
* send it into the turn.
|
||||
*/
|
||||
whileRunning: WhileRunning,
|
||||
/** Settings new tasks of an agent start with: agent id → option id → value. */
|
||||
defaults: { [key in string]: { [key in string]: string } },
|
||||
};
|
||||
|
||||
/** Where an agent comes from. */
|
||||
@@ -5994,13 +6046,13 @@ export type MavenSettings = {
|
||||
activeProfiles?: string,
|
||||
};
|
||||
|
||||
/** Tools › MCP Server: agents use the IDE's built-in tools. */
|
||||
/** Agents › MCP Server: agents use the IDE's built-in tools. */
|
||||
export type McpServerSettings = {
|
||||
/** Serve the tools on 127.0.0.1 while the IDE runs. */
|
||||
enabled?: boolean,
|
||||
};
|
||||
|
||||
/** What Settings › Tools › MCP Server shows. */
|
||||
/** What Settings › Agents › MCP Server shows. */
|
||||
export type McpServerStatus = {
|
||||
running: boolean,
|
||||
/** Why it isn't running though turned on. */
|
||||
@@ -7899,6 +7951,9 @@ export type SemanticTokens = {
|
||||
tokenModifiers: string[],
|
||||
};
|
||||
|
||||
/** The key that sends a message; the other one starts a new line. */
|
||||
export type SendKey = "enter" | "modEnter";
|
||||
|
||||
/** A running language server instance. */
|
||||
export type ServerId = number;
|
||||
|
||||
@@ -8035,9 +8090,9 @@ export type Settings_Deserialize = {
|
||||
/** Build, Execution, Deployment › Run Targets. */
|
||||
runTargets?: RunTarget[],
|
||||
cmake?: CMakeSettings_Deserialize,
|
||||
/** Tools › Agents › Chat. */
|
||||
agents?: AgentSettings,
|
||||
/** Tools › MCP Server (global only). */
|
||||
/** Settings › Agents. */
|
||||
agents?: AgentSettings_Deserialize,
|
||||
/** Agents › MCP Server (global only). */
|
||||
mcpServer?: McpServerSettings,
|
||||
/** Plugin settings (`[contributes.settings]`): plugin id → its values by key. */
|
||||
plugins?: { [key in string]: { [key in string]: unknown } },
|
||||
@@ -8097,9 +8152,9 @@ export type Settings_Serialize = {
|
||||
/** Build, Execution, Deployment › Run Targets. */
|
||||
runTargets: RunTarget[],
|
||||
cmake: CMakeSettings_Serialize,
|
||||
/** Tools › Agents › Chat. */
|
||||
agents: AgentSettings,
|
||||
/** Tools › MCP Server (global only). */
|
||||
/** Settings › Agents. */
|
||||
agents: AgentSettings_Serialize,
|
||||
/** Agents › MCP Server (global only). */
|
||||
mcpServer: McpServerSettings,
|
||||
/** Plugin settings (`[contributes.settings]`): plugin id → its values by key. */
|
||||
plugins: { [key in string]: { [key in string]: unknown } },
|
||||
@@ -8673,6 +8728,11 @@ export type ThemeDef = {
|
||||
|
||||
export type ThemeKind = "dark" | "light";
|
||||
|
||||
/** How the agent's reasoning shows. */
|
||||
export type ThinkingDisplay =
|
||||
/** Open while it streams, folded once it ends. */
|
||||
"auto" | "expanded" | "collapsed";
|
||||
|
||||
export type ThreadInfo = {
|
||||
id: number,
|
||||
name: string,
|
||||
@@ -8720,6 +8780,13 @@ export type TomcatInstallation_Serialize = {
|
||||
tomee: boolean,
|
||||
};
|
||||
|
||||
/** How tool calls between messages show. */
|
||||
export type ToolCallDisplay =
|
||||
/** A run of calls is one line saying what they did ("Read 4 files · Edited 2 files"). */
|
||||
"grouped" |
|
||||
/** Each call on its own line. */
|
||||
"each";
|
||||
|
||||
/** UI-facing state of one tool. */
|
||||
export type ToolInfo = {
|
||||
/** Server / adapter name from `languages/*.toml`. */
|
||||
@@ -9066,6 +9133,8 @@ export type WebReferenceKind =
|
||||
/** `var(--name)`: the custom property's declarations. */
|
||||
"cssVariable";
|
||||
|
||||
export type WhileRunning = "queue" | "steer";
|
||||
|
||||
/** A request that the invoking window acknowledges through plugin_answer_command. */
|
||||
export type WindowRequest = WindowRequest_Serialize | WindowRequest_Deserialize;
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
/* The agent list beside the selected agent, as in Settings › Plugins. */
|
||||
.split {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 420px;
|
||||
border-radius: var(--radius-small);
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.master {
|
||||
display: flex;
|
||||
flex: 0 1 240px;
|
||||
flex-direction: column;
|
||||
min-width: 150px;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.search {
|
||||
margin: 8px;
|
||||
}
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 4px 4px;
|
||||
}
|
||||
.listSection {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 100%;
|
||||
padding: 0 8px 4px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.agentRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.agentName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agentMissing .agentName {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.tag {
|
||||
flex: none;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.tagAccent {
|
||||
color: var(--accent);
|
||||
}
|
||||
.empty {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
text-align: center;
|
||||
}
|
||||
.registryState {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 12px 16px 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.heading h3 {
|
||||
margin: 0;
|
||||
color: var(--fg-strong);
|
||||
font-size: var(--fs-ui);
|
||||
font-weight: 600;
|
||||
}
|
||||
.facts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.facts > span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dot {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--fg-subtle);
|
||||
}
|
||||
.dotFound {
|
||||
background: var(--success);
|
||||
}
|
||||
.dotCustom {
|
||||
background: var(--accent);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 6px;
|
||||
}
|
||||
.progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 360px;
|
||||
margin: 10px 0 0 34px;
|
||||
}
|
||||
.notice,
|
||||
.description,
|
||||
.warning,
|
||||
.links,
|
||||
.error {
|
||||
margin: 10px 0 0 34px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.notice {
|
||||
color: var(--accent);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.description {
|
||||
color: var(--fg);
|
||||
}
|
||||
.warning {
|
||||
color: var(--warning);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.registryState .warning,
|
||||
.registryState .error {
|
||||
margin: 0;
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: var(--fs-small);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.link {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
font-size: var(--fs-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.thirdParty {
|
||||
margin: 20px 0 0;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
|
||||
/* IDEA titled separator: "Launch ─────────", its rows indented under it. */
|
||||
.section {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.sectionTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 22px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.sectionRule {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.sectionBody {
|
||||
padding-left: 18px;
|
||||
}
|
||||
.sectionBody > .error {
|
||||
margin-left: 0;
|
||||
}
|
||||
.text {
|
||||
margin: 4px 0;
|
||||
color: var(--fg);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.text code,
|
||||
.launch {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.text code {
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
/* "Label: control" rows; a hint sits under its control. */
|
||||
.form {
|
||||
display: grid;
|
||||
/* Wide enough for the longest label, so the sections' controls line up. */
|
||||
grid-template-columns: minmax(150px, max-content) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
column-gap: 10px;
|
||||
row-gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.label {
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.label::after {
|
||||
content: ":";
|
||||
}
|
||||
.control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 28px;
|
||||
}
|
||||
.rowHint {
|
||||
grid-column: 2;
|
||||
margin: 0 0 6px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.form > .rowHint:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: 480px;
|
||||
}
|
||||
.field > :first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.select {
|
||||
display: flex;
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Every select of a form is as wide as the others, as IDEA lays out combo boxes. */
|
||||
.select > button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.launch {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--fg-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
/**
|
||||
* Settings › Agents › Coding Agents: one searchable list of every agent, those this machine can run first
|
||||
* (built in, installed from the ACP Registry, or defined in an agent file), then those it
|
||||
* can install. The selected agent's page holds its actions, the settings its new tasks
|
||||
* start with, and how it is launched. Launch edits are staged until Apply; installs run in
|
||||
* the background and keep running after the page closes.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import type { AgentConfigOption, AgentProvider, AgentRegistry, RegistryAgent, RequestId } from "@/bindings";
|
||||
import { isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { tx, useTxTemplate } from "@/platform/i18n";
|
||||
import { openExternalUrl } from "@/platform/openUrl";
|
||||
import clsx from "clsx";
|
||||
import { Button, Icon, IconButton, Input, List, ProgressBar, SearchField, Select, Spinner, T } from "@/ui";
|
||||
import { useSettingsForm } from "@/workbench/contributions";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { providerIcon } from "./model";
|
||||
import {
|
||||
DISTRIBUTION_LABEL,
|
||||
afterInstall,
|
||||
afterRemove,
|
||||
matchesQuery,
|
||||
sortAgents,
|
||||
type CommandsBackend,
|
||||
type RegistryBackend,
|
||||
} from "./registry";
|
||||
import styles from "./AgentList.module.css";
|
||||
|
||||
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
/** How often the page checks on installs it did not start. */
|
||||
const BACKGROUND_POLL_MS = 1500;
|
||||
|
||||
/** An agent of the list: one this machine knows (`provider`), one of the registry, or both. */
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: AgentProvider | null;
|
||||
registry: RegistryAgent | null;
|
||||
}
|
||||
|
||||
type Row = { section: string } | Agent;
|
||||
const isSection = (r: Row): r is { section: string } => "section" in r;
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
/** Absent for an install this page did not start (stopped from the background tasks). */
|
||||
request: RequestId | null;
|
||||
fraction: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const runnable = (a: Agent) =>
|
||||
a.provider
|
||||
? a.provider.installed || a.provider.source.kind !== "builtin" || a.provider.commandOverridden
|
||||
: a.registry?.installed != null;
|
||||
|
||||
function matches(a: Agent, query: string): boolean {
|
||||
if (a.registry) return matchesQuery(a.registry, query);
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const text = `${a.name} ${a.id}`.toLowerCase();
|
||||
return words.every((w) => text.includes(w));
|
||||
}
|
||||
|
||||
/**
|
||||
* The list's rows: runnable agents in the order the backend gives them, then the ones that
|
||||
* can be installed (built-ins not found first, then the registry's), each under a header.
|
||||
*/
|
||||
export function agentRows(providers: AgentProvider[], registry: RegistryAgent[], query: string): Row[] {
|
||||
const byId = new Map(registry.map((r) => [r.id, r]));
|
||||
const known = new Set(providers.map((p) => p.id));
|
||||
const all: Agent[] = [
|
||||
...providers.map((p) => ({ id: p.id, name: p.name, provider: p, registry: byId.get(p.id) ?? null })),
|
||||
...sortAgents(registry.filter((r) => !known.has(r.id))).map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
provider: null,
|
||||
registry: r,
|
||||
})),
|
||||
].filter((a) => matches(a, query));
|
||||
const installed = all.filter(runnable);
|
||||
const available = all.filter((a) => !runnable(a));
|
||||
return [
|
||||
...(installed.length ? [{ section: "Installed" }, ...installed] : []),
|
||||
...(available.length ? [{ section: "Available" }, ...available] : []),
|
||||
];
|
||||
}
|
||||
|
||||
/** What the agent needs, if anything, as a trailing tag in the list. */
|
||||
export function agentStatus(agent: AgentProvider): { label: string; tone: "muted" | "accent" } | null {
|
||||
if (agent.source.kind === "registry" && agent.source.update) return { label: "Update", tone: "accent" };
|
||||
if (agent.commandOverridden || agent.source.kind === "custom") return { label: "Custom", tone: "accent" };
|
||||
if (!agent.installed) return { label: "Not found", tone: "muted" };
|
||||
return null;
|
||||
}
|
||||
|
||||
function AgentItem({ agent, working }: { agent: Agent; working: boolean }) {
|
||||
const status = agent.provider
|
||||
? agentStatus(agent.provider)
|
||||
: agent.registry?.installed != null && agent.registry.update
|
||||
? ({ label: "Update", tone: "accent" } as const)
|
||||
: null;
|
||||
return (
|
||||
<div className={clsx(styles.agentRow, !runnable(agent) && styles.agentMissing)}>
|
||||
<Icon name={providerIcon(agent.id)} size={16} />
|
||||
<span className={styles.agentName}>{agent.name}</span>{" "}
|
||||
{working ? (
|
||||
<Spinner size={12} />
|
||||
) : (
|
||||
status && (
|
||||
<span className={clsx(styles.tag, status.tone === "accent" && styles.tagAccent)}>
|
||||
<T>{status.label}</T>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentList({
|
||||
backend,
|
||||
registry: registryBackend,
|
||||
onChanged,
|
||||
}: {
|
||||
backend: CommandsBackend;
|
||||
registry: RegistryBackend;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [providers, setProviders] = useState<AgentProvider[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [registry, setRegistry] = useState<AgentRegistry | null>(null);
|
||||
const [registryError, setRegistryError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [job, setJob] = useState<Job | null>(null);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
backend.providers().then(
|
||||
(list) => live && setProviders(list),
|
||||
(e: unknown) => live && setError(errorText(e)),
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [backend]);
|
||||
useEffect(() => {
|
||||
const request = nextRequestId();
|
||||
let live = true;
|
||||
registryBackend
|
||||
.list(request)
|
||||
.then((r) => live && setRegistry(r))
|
||||
.catch((e: unknown) => live && !isCancelled(e) && setRegistryError(errorText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
registryBackend.cancel(request);
|
||||
};
|
||||
}, [registryBackend]);
|
||||
const refresh = useCallback(async () => {
|
||||
setProviders(await backend.providers());
|
||||
}, [backend]);
|
||||
const changed = useCallback(() => {
|
||||
void refresh().catch((e: unknown) => setError(errorText(e)));
|
||||
onChanged?.();
|
||||
}, [refresh, onChanged]);
|
||||
|
||||
// Installs started before the page opened: follow them until they end.
|
||||
const background = registry?.agents.some((a) => a.installing) ?? false;
|
||||
useEffect(() => {
|
||||
if (!background) return;
|
||||
let live = true;
|
||||
let request: RequestId | null = null;
|
||||
const timer = setInterval(() => {
|
||||
if (request != null) return;
|
||||
const r = (request = nextRequestId());
|
||||
registryBackend
|
||||
.list(r)
|
||||
.then((next) => {
|
||||
if (!live) return;
|
||||
setRegistry(next);
|
||||
if (!next.agents.some((a) => a.installing)) changed();
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (request === r) request = null;
|
||||
});
|
||||
}, BACKGROUND_POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(timer);
|
||||
if (request != null) registryBackend.cancel(request);
|
||||
};
|
||||
}, [registryBackend, background, changed]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => (providers ? agentRows(providers, registry?.agents ?? [], query) : []),
|
||||
[providers, registry, query],
|
||||
);
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
if (!providers) return <Spinner size={14} />;
|
||||
|
||||
const setAgentError = (id: string, message: string | null) =>
|
||||
setErrors((all) => {
|
||||
const { [id]: _, ...rest } = all;
|
||||
return message ? { ...rest, [id]: message } : rest;
|
||||
});
|
||||
|
||||
const install = async (agent: Agent) => {
|
||||
const entry = agent.registry;
|
||||
if (!entry) return;
|
||||
if (
|
||||
entry.noChecksum &&
|
||||
!(await confirm({
|
||||
title: tx`Install ${agent.name}`,
|
||||
message: tx`${agent.name} provides no checksum for its download, so the downloaded file cannot be verified. Install it anyway?`,
|
||||
ok: tx("Install"),
|
||||
icon: "warning",
|
||||
}))
|
||||
)
|
||||
return;
|
||||
const request = nextRequestId();
|
||||
setAgentError(agent.id, null);
|
||||
setJob({ id: agent.id, request, fraction: null, message: tx`Installing ${agent.name} ${entry.version}…` });
|
||||
registryBackend
|
||||
.install(agent.id, request, (e) =>
|
||||
setJob((j) =>
|
||||
j && j.request === request
|
||||
? e.type === "progress"
|
||||
? { ...j, fraction: e.fraction, message: e.message }
|
||||
: { ...j, message: e.text }
|
||||
: j,
|
||||
),
|
||||
)
|
||||
.then(() => {
|
||||
setRegistry((r) => r && { ...r, agents: afterInstall(r.agents, agent.id) });
|
||||
changed();
|
||||
})
|
||||
.catch((e: unknown) => !isCancelled(e) && setAgentError(agent.id, errorText(e)))
|
||||
.finally(() => setJob(null));
|
||||
};
|
||||
|
||||
const remove = async (agent: Agent) => {
|
||||
if (
|
||||
!(await confirm({
|
||||
title: tx`Remove ${agent.name}`,
|
||||
message: tx`Remove ${agent.name} from this computer? Its tasks stay, but can't continue until it is installed again.`,
|
||||
ok: tx("Remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
setAgentError(agent.id, null);
|
||||
setRemoving(agent.id);
|
||||
try {
|
||||
await registryBackend.remove(agent.id);
|
||||
setRegistry((r) => r && { ...r, agents: afterRemove(r.agents, agent.id) });
|
||||
changed();
|
||||
} catch (e) {
|
||||
setAgentError(agent.id, errorText(e));
|
||||
} finally {
|
||||
setRemoving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const found = rows.findIndex((r) => !isSection(r) && r.id === selected);
|
||||
const index = found >= 0 ? found : rows.findIndex((r) => !isSection(r));
|
||||
const agent = index >= 0 ? (rows[index] as Agent) : null;
|
||||
const jobOf = (a: Agent): Job | null =>
|
||||
job?.id === a.id
|
||||
? job
|
||||
: a.registry?.installing
|
||||
? { id: a.id, request: null, fraction: null, message: tx`Installing ${a.name} in the background…` }
|
||||
: null;
|
||||
const busy = job != null || removing != null || background;
|
||||
|
||||
return (
|
||||
<div className={styles.split}>
|
||||
<div className={styles.master}>
|
||||
<SearchField
|
||||
className={styles.search}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={tx("Search agents")}
|
||||
aria-label="Search agents"
|
||||
clearable
|
||||
/>
|
||||
{rows.length ? (
|
||||
<List
|
||||
className={styles.list}
|
||||
items={rows}
|
||||
getKey={(r) => (isSection(r) ? `section:${r.section}` : r.id)}
|
||||
isSection={isSection}
|
||||
renderSection={(r) => (
|
||||
<div className={styles.listSection}>
|
||||
<T>{(r as { section: string }).section}</T>
|
||||
</div>
|
||||
)}
|
||||
activeIndex={index}
|
||||
onActiveChange={(i) => {
|
||||
const r = rows[i];
|
||||
if (r && !isSection(r)) setSelected(r.id);
|
||||
}}
|
||||
rowHeight={28}
|
||||
sectionHeight={26}
|
||||
aria-label={tx("Agents")}
|
||||
renderItem={(r) => <AgentItem agent={r as Agent} working={jobOf(r as Agent) != null} />}
|
||||
/>
|
||||
) : (
|
||||
query && (
|
||||
<p className={styles.empty}>
|
||||
<T>No agents match</T>
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{(!registry || registry.warning) && (
|
||||
<div className={styles.registryState}>
|
||||
{registryError ? (
|
||||
<span className={styles.error} role="alert">
|
||||
{tx`Cannot load the ACP Registry: ${registryError}`}
|
||||
</span>
|
||||
) : registry?.warning ? (
|
||||
<span className={styles.warning} role="status">
|
||||
{registry.warning}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Spinner size={12} />
|
||||
<T>Loading the ACP Registry…</T>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{agent && (
|
||||
<Details
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
backend={backend}
|
||||
refresh={refresh}
|
||||
onChanged={onChanged}
|
||||
job={jobOf(agent)}
|
||||
busy={busy}
|
||||
removing={removing === agent.id}
|
||||
error={errors[agent.id] ?? null}
|
||||
onInstall={() => void install(agent)}
|
||||
onRemove={() => void remove(agent)}
|
||||
onCancel={(request) => registryBackend.cancel(request)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A titled separator over its indented rows: "Launch ─────────". */
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className={styles.section} aria-label={tx(title)}>
|
||||
<div className={styles.sectionTitle}>
|
||||
<T>{title}</T>
|
||||
<span aria-hidden className={styles.sectionRule} />
|
||||
</div>
|
||||
<div className={styles.sectionBody}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Label: control", with an optional hint under the control. */
|
||||
function Row({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
htmlFor?: string;
|
||||
hint?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{htmlFor ? (
|
||||
<label className={styles.label} htmlFor={htmlFor}>
|
||||
{label}
|
||||
</label>
|
||||
) : (
|
||||
<span className={styles.label}>{label}</span>
|
||||
)}
|
||||
<div className={styles.control}>{children}</div>
|
||||
{hint && <p className={styles.rowHint}>{hint}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Details({
|
||||
agent,
|
||||
backend,
|
||||
refresh,
|
||||
onChanged,
|
||||
job,
|
||||
busy,
|
||||
removing,
|
||||
error,
|
||||
onInstall,
|
||||
onRemove,
|
||||
onCancel,
|
||||
}: {
|
||||
agent: Agent;
|
||||
backend: CommandsBackend;
|
||||
refresh: () => Promise<void>;
|
||||
onChanged?: () => void;
|
||||
job: Job | null;
|
||||
busy: boolean;
|
||||
removing: boolean;
|
||||
error: string | null;
|
||||
onInstall: () => void;
|
||||
onRemove: () => void;
|
||||
onCancel: (request: RequestId) => void;
|
||||
}) {
|
||||
const { provider, registry } = agent;
|
||||
const installed = provider?.source.kind === "registry" || (!provider && registry?.installed != null);
|
||||
const canInstall = registry?.distribution != null;
|
||||
const update =
|
||||
provider?.source.kind === "registry" ? provider.source.update : registry?.update ? registry.version : null;
|
||||
const facts = provider
|
||||
? [providerStatus(provider)]
|
||||
: [
|
||||
registry?.version,
|
||||
registry?.distribution ? tx(DISTRIBUTION_LABEL[registry.distribution]) : null,
|
||||
registry?.authors.join(", ") || null,
|
||||
registry?.license,
|
||||
];
|
||||
return (
|
||||
<section className={styles.details} aria-label={agent.name}>
|
||||
<header className={styles.header}>
|
||||
<Icon name={providerIcon(agent.id)} size={24} />
|
||||
<div className={styles.heading}>
|
||||
<h3>{agent.name}</h3>
|
||||
<div className={styles.facts}>
|
||||
{provider && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={clsx(
|
||||
styles.dot,
|
||||
provider.commandOverridden || provider.source.kind === "custom"
|
||||
? styles.dotCustom
|
||||
: provider.installed && styles.dotFound,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span>{facts.filter(Boolean).join(" · ")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
{job ? (
|
||||
job.request != null && (
|
||||
<Button size="small" onClick={() => onCancel(job.request!)}>
|
||||
Cancel
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{!runnable(agent) && canInstall && (
|
||||
<Button size="small" variant="primary" disabled={busy} onClick={onInstall}>
|
||||
Install
|
||||
</Button>
|
||||
)}
|
||||
{installed && update && canInstall && (
|
||||
<Button size="small" variant="primary" disabled={busy} onClick={onInstall}>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
{installed && (
|
||||
<Button size="small" disabled={busy} onClick={onRemove}>
|
||||
{removing ? "Removing…" : "Remove"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{job && (
|
||||
<div className={styles.progress}>
|
||||
<ProgressBar value={job.fraction} aria-label={tx`Installing ${agent.name}`} />
|
||||
<span className={styles.hint}>{job.message}</span>
|
||||
</div>
|
||||
)}
|
||||
{!job && installed && update && <p className={styles.notice}>{tx`${update} is available`}</p>}
|
||||
{error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{registry?.description && <p className={styles.description}>{registry.description}</p>}
|
||||
{!provider && registry?.noChecksum && (
|
||||
<p className={styles.warning}>
|
||||
<T>No checksum provided: the download can't be verified.</T>
|
||||
</p>
|
||||
)}
|
||||
{registry && (registry.repository || registry.website) && (
|
||||
<div className={styles.links}>
|
||||
{registry.repository && <ExternalLink url={registry.repository} label="Repository" />}
|
||||
{registry.website && registry.website !== registry.repository && (
|
||||
<ExternalLink url={registry.website} label="Website" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{provider && <ProviderSettings agent={provider} backend={backend} refresh={refresh} onChanged={onChanged} />}
|
||||
{registry && (
|
||||
<p className={styles.thirdParty}>
|
||||
<T>
|
||||
Agents in the ACP Registry are third-party software. They run on this computer with your permissions and can
|
||||
read and change your files. Install only agents you trust.
|
||||
</T>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function providerStatus(agent: AgentProvider): string {
|
||||
if (agent.source.kind === "registry") return tx`ACP Registry · ${agent.source.version}`;
|
||||
if (agent.source.kind === "custom") return tx("Agent file");
|
||||
if (agent.commandOverridden) return tx("Custom executable");
|
||||
return agent.installed ? tx("Found on PATH") : tx("Not found on PATH");
|
||||
}
|
||||
|
||||
function ProviderSettings({
|
||||
agent,
|
||||
backend,
|
||||
refresh,
|
||||
onChanged,
|
||||
}: {
|
||||
agent: AgentProvider;
|
||||
backend: CommandsBackend;
|
||||
refresh: () => Promise<void>;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const form = useSettingsForm();
|
||||
const base = agent.commandOverridden ? agent.program : "";
|
||||
const { value, current, set, saving } = form.useExternal(`agents.${agent.id}.command`, base, async (draft) => {
|
||||
const command = draft.trim() || null;
|
||||
await backend.set(agent.id, command);
|
||||
await refresh();
|
||||
onChanged?.();
|
||||
return command === agent.defaultProgram ? "" : (command ?? "");
|
||||
});
|
||||
const envBase = formatEnv(agent.env);
|
||||
const env = form.useExternal(`agents.${agent.id}.env`, envBase, async (draft) => {
|
||||
const parsed = parseEnv(draft);
|
||||
await backend.setEnv(agent.id, parsed);
|
||||
await refresh();
|
||||
onChanged?.();
|
||||
return formatEnv(parsed);
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// An edit can have been staged by a previous page instance. Its Apply callback
|
||||
// refreshes that instance; the mounted one follows the newly applied value too.
|
||||
useEffect(() => {
|
||||
if (!saving && current !== base) void refresh().catch((e: unknown) => setError(errorText(e)));
|
||||
}, [base, current, saving, refresh]);
|
||||
const launch = [agent.defaultProgram, ...agent.defaultArgs].join(" ");
|
||||
const envEditable = agent.commandReadOnlyReason !== "registry" && agent.commandReadOnlyReason !== "customAgent";
|
||||
return (
|
||||
<>
|
||||
{agent.loginHint && (
|
||||
<Section title="Sign In">
|
||||
<p className={styles.text}>
|
||||
<LoginHint text={agent.loginHint} />
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
<Section title="New Tasks">
|
||||
<AgentDefaults agent={agent} />
|
||||
</Section>
|
||||
<Section title="Launch">
|
||||
<div className={styles.form}>
|
||||
<Row label={<T>Command</T>}>
|
||||
<code className={styles.launch} title={launch}>
|
||||
{launch}
|
||||
</code>
|
||||
</Row>
|
||||
{agent.commandEditable ? (
|
||||
<Row
|
||||
label={<T>Executable</T>}
|
||||
htmlFor={`agent-executable-${agent.id}`}
|
||||
hint={<T>Leave empty to find the executable on PATH. Changes apply to new sessions.</T>}
|
||||
>
|
||||
<div className={styles.field}>
|
||||
<Input
|
||||
id={`agent-executable-${agent.id}`}
|
||||
mono
|
||||
value={value}
|
||||
disabled={saving}
|
||||
placeholder={tx`Auto-detect (${agent.defaultProgram})`}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="folder-open"
|
||||
title="Browse…"
|
||||
disabled={saving}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const path = await backend.chooseExecutable(tx`Select ${agent.name}`, value);
|
||||
if (path) set(path);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(errorText(e));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{value && <IconButton icon="undo" title="Reset" disabled={saving} onClick={() => set("")} />}
|
||||
</div>
|
||||
</Row>
|
||||
) : (
|
||||
<p className={styles.rowHint}>
|
||||
{agent.commandReadOnlyReason === "packageRunner" ? (
|
||||
<T>This agent runs through a package runner (npm or uv) and has no executable to choose.</T>
|
||||
) : agent.commandReadOnlyReason === "registry" ? (
|
||||
<T>This agent is installed from the ACP Registry, which launches it.</T>
|
||||
) : (
|
||||
<T>This agent is configured in its agent file.</T>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{envEditable && (
|
||||
<Row
|
||||
label={<T>Environment variables</T>}
|
||||
htmlFor={`agent-env-${agent.id}`}
|
||||
hint={
|
||||
<T>Passed to the agent process, replacing variables of the same name. Changes apply to new sessions.</T>
|
||||
}
|
||||
>
|
||||
<div className={styles.field}>
|
||||
<Input
|
||||
id={`agent-env-${agent.id}`}
|
||||
mono
|
||||
value={env.value}
|
||||
disabled={env.saving}
|
||||
placeholder="NAME=value; OTHER=value"
|
||||
onChange={(e) => env.set(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** `NAME=value` pairs separated by `;` or new lines. */
|
||||
function parseEnv(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const raw of text.split(/[;\n]/)) {
|
||||
const pair = raw.trim();
|
||||
if (!pair) continue;
|
||||
const eq = pair.indexOf("=");
|
||||
const key = eq < 0 ? "" : pair.slice(0, eq).trim();
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(tx`Expected NAME=value: ${pair}`);
|
||||
env[key] = pair.slice(eq + 1).trim();
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const formatEnv = (env: Record<string, string>) =>
|
||||
Object.entries(env)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
|
||||
/** Login hints are plain text with optional backtick-delimited command spans. */
|
||||
function LoginHint({ text }: { text: string }) {
|
||||
const translate = useTxTemplate();
|
||||
return translate(text)
|
||||
.split(/(`[^`]+`)/g)
|
||||
.map((part, i) => (i % 2 === 1 ? <code key={i}>{part.slice(1, -1)}</code> : part));
|
||||
}
|
||||
|
||||
function ExternalLink({ url, label }: { url: string; label: string }) {
|
||||
// Links come from the registry: web pages only.
|
||||
if (!/^https?:\/\//i.test(url)) return null;
|
||||
return (
|
||||
<button type="button" className={styles.link} title={url} onClick={() => void openExternalUrl(url)}>
|
||||
<T>{label}</T>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The options a new task of `agent` starts with: each the agent's own default unless chosen. */
|
||||
export function defaultOptions(agent: AgentProvider, chosen: Record<string, string>): AgentConfigOption[] {
|
||||
const base = agent.config.filter((o) => o.category !== "thought_level" && o.values.length > 1);
|
||||
const modelOption = base.find((o) => o.category === "model");
|
||||
const model = (modelOption && chosen[modelOption.id]) || modelOption?.current;
|
||||
const levels = (model && agent.thoughtLevels?.[model]) || [];
|
||||
return [...base, ...levels.filter((o) => o.values.length > 1)];
|
||||
}
|
||||
|
||||
function AgentDefaults({ agent }: { agent: AgentProvider }) {
|
||||
const form = useSettingsForm();
|
||||
const all = (form.useValue("agents.defaults") ?? {}) as Record<string, Record<string, string>>;
|
||||
const chosen = all[agent.id] ?? {};
|
||||
const options = defaultOptions(agent, chosen);
|
||||
if (!options.length)
|
||||
return (
|
||||
<p className={styles.text}>
|
||||
<T>The agent's models and modes can be chosen here after its first session.</T>
|
||||
</p>
|
||||
);
|
||||
const choose = (id: string, value: string) => {
|
||||
const { [id]: _, ...rest } = chosen;
|
||||
const next = value ? { ...rest, [id]: value } : rest;
|
||||
const { [agent.id]: __, ...others } = all;
|
||||
form.set("agents.defaults", Object.keys(next).length ? { ...others, [agent.id]: next } : others);
|
||||
};
|
||||
return (
|
||||
<div className={styles.form}>
|
||||
{options.map((o) => {
|
||||
const fallback = o.values.find((v) => v.value === o.current)?.name;
|
||||
return (
|
||||
<Row key={o.id} label={o.name} htmlFor={`agent-default-${agent.id}-${o.id}`}>
|
||||
<div className={styles.select}>
|
||||
<Select
|
||||
id={`agent-default-${agent.id}-${o.id}`}
|
||||
value={chosen[o.id] ?? ""}
|
||||
options={[
|
||||
{ value: "", label: fallback ? tx`Agent's default (${fallback})` : tx("Agent's default") },
|
||||
...o.values.map((v) => ({
|
||||
value: v.value,
|
||||
label: v.name,
|
||||
verbatim: true,
|
||||
detail: v.description ?? undefined,
|
||||
})),
|
||||
]}
|
||||
onChange={(value) => choose(o.id, value)}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
<p className={styles.rowHint}>
|
||||
<T>What you pick on the New Task page wins; these apply until then and after switching agents.</T>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,134 +5,3 @@
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The compact variant may shrink in toolbars; here it keeps its height above the panes. */
|
||||
.tabs {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.notice,
|
||||
.warning,
|
||||
.error,
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-size: var(--fs-small);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.notice {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.empty {
|
||||
padding: 16px 4px;
|
||||
color: var(--fg-subtle);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 240px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
.loading {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
|
||||
.rows {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.titleLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.name {
|
||||
color: var(--fg-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.version {
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 10px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-tiny);
|
||||
}
|
||||
.link {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.link:focus-visible {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.progressText {
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-tiny);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.actions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -1,54 +1,8 @@
|
||||
/**
|
||||
* Settings › Tools › Agents: the launch command of built-in agents (Claude Code…), and
|
||||
* agents of the ACP Registry this machine can run, with Install / Update / Remove.
|
||||
* Installs run in the background with progress, and keep running after the page closes
|
||||
* (as a background task of the project windows); the page then shows them as installing.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { AgentRegistry, RegistryAgent, RequestId } from "@/bindings";
|
||||
import { isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { openExternalUrl } from "@/platform/openUrl";
|
||||
import { Badge, Button, Icon, ProgressBar, SearchField, Spinner, T, Tabs } from "@/ui";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { providerIcon } from "./model";
|
||||
import {
|
||||
DISTRIBUTION_LABEL,
|
||||
afterInstall,
|
||||
afterRemove,
|
||||
matchesQuery,
|
||||
rowActions,
|
||||
sortAgents,
|
||||
tauriCommands,
|
||||
tauriRegistry,
|
||||
type CommandsBackend,
|
||||
type RegistryBackend,
|
||||
} from "./registry";
|
||||
import { BuiltinAgents } from "./BuiltinAgents";
|
||||
/** Settings › Agents › Coding Agents. */
|
||||
import { AgentList } from "./AgentList";
|
||||
import { tauriCommands, tauriRegistry, type CommandsBackend, type RegistryBackend } from "./registry";
|
||||
import styles from "./AgentRegistry.module.css";
|
||||
|
||||
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
/** How often the page checks on installs it did not start. */
|
||||
const BACKGROUND_POLL_MS = 1500;
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
fraction: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface PageJob extends Job {
|
||||
request: RequestId;
|
||||
}
|
||||
|
||||
/** An install started before the page opened; its progress shows in the background tasks. */
|
||||
const backgroundJob = (agent: RegistryAgent): Job => ({
|
||||
id: agent.id,
|
||||
fraction: null,
|
||||
message: tx`Installing ${agent.name} in the background…`,
|
||||
});
|
||||
|
||||
export function AgentRegistryPage({
|
||||
onChanged,
|
||||
backend = tauriRegistry,
|
||||
@@ -59,284 +13,9 @@ export function AgentRegistryPage({
|
||||
backend?: RegistryBackend;
|
||||
commandBackend?: CommandsBackend;
|
||||
}) {
|
||||
const [tab, setTab] = useState("builtin");
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
<Tabs
|
||||
variant="compact"
|
||||
className={styles.tabs}
|
||||
items={[
|
||||
{ id: "builtin", label: "Built-in" },
|
||||
{ id: "registry", label: "ACP Registry" },
|
||||
]}
|
||||
activeId={tab}
|
||||
onSelect={setTab}
|
||||
aria-label={tx("Agents")}
|
||||
/>
|
||||
{tab === "builtin" ? (
|
||||
<BuiltinAgents backend={commandBackend} onChanged={onChanged} />
|
||||
) : (
|
||||
<RegistryList backend={backend} onChanged={onChanged} />
|
||||
)}
|
||||
<AgentList backend={commandBackend} registry={backend} onChanged={onChanged} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegistryList({ backend, onChanged }: { backend: RegistryBackend; onChanged?: () => void }) {
|
||||
const [registry, setRegistry] = useState<AgentRegistry | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [job, setJob] = useState<PageJob | null>(null);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const request = nextRequestId();
|
||||
let live = true;
|
||||
backend
|
||||
.list(request)
|
||||
.then((r) => live && setRegistry(r))
|
||||
.catch((e: unknown) => live && !isCancelled(e) && setLoadError(errorText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
backend.cancel(request);
|
||||
};
|
||||
}, [backend]);
|
||||
|
||||
// Installs started before the page opened: follow them until they end.
|
||||
const background = registry?.agents.some((a) => a.installing) ?? false;
|
||||
useEffect(() => {
|
||||
if (!background) return;
|
||||
let live = true;
|
||||
let request: RequestId | null = null;
|
||||
const timer = setInterval(() => {
|
||||
if (request != null) return;
|
||||
const r = (request = nextRequestId());
|
||||
backend
|
||||
.list(r)
|
||||
.then((next) => {
|
||||
if (!live) return;
|
||||
setRegistry(next);
|
||||
if (!next.agents.some((a) => a.installing)) onChanged?.();
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (request === r) request = null;
|
||||
});
|
||||
}, BACKGROUND_POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(timer);
|
||||
if (request != null) backend.cancel(request);
|
||||
};
|
||||
}, [backend, background, onChanged]);
|
||||
|
||||
const agents = useMemo(
|
||||
() => sortAgents((registry?.agents ?? []).filter((a) => matchesQuery(a, query))),
|
||||
[registry, query],
|
||||
);
|
||||
const busy = job != null || removing != null || background;
|
||||
|
||||
const setError = (id: string, error: string | null) =>
|
||||
setErrors((all) => {
|
||||
const { [id]: _, ...rest } = all;
|
||||
return error ? { ...rest, [id]: error } : rest;
|
||||
});
|
||||
|
||||
const install = async (agent: RegistryAgent) => {
|
||||
if (
|
||||
agent.noChecksum &&
|
||||
!(await confirm({
|
||||
title: tx`Install ${agent.name}`,
|
||||
message: tx`${agent.name} provides no checksum for its download, so the downloaded file cannot be verified. Install it anyway?`,
|
||||
ok: tx("Install"),
|
||||
icon: "warning",
|
||||
}))
|
||||
)
|
||||
return;
|
||||
const request = nextRequestId();
|
||||
setError(agent.id, null);
|
||||
setJob({ id: agent.id, request, fraction: null, message: tx`Installing ${agent.name} ${agent.version}…` });
|
||||
backend
|
||||
.install(agent.id, request, (e) =>
|
||||
setJob((j) =>
|
||||
j && j.request === request
|
||||
? e.type === "progress"
|
||||
? { ...j, fraction: e.fraction, message: e.message }
|
||||
: { ...j, message: e.text }
|
||||
: j,
|
||||
),
|
||||
)
|
||||
.then(() => {
|
||||
setRegistry((r) => r && { ...r, agents: afterInstall(r.agents, agent.id) });
|
||||
onChanged?.();
|
||||
})
|
||||
.catch((e: unknown) => !isCancelled(e) && setError(agent.id, errorText(e)))
|
||||
.finally(() => setJob(null));
|
||||
};
|
||||
|
||||
const remove = (agent: RegistryAgent) => {
|
||||
setError(agent.id, null);
|
||||
setRemoving(agent.id);
|
||||
backend
|
||||
.remove(agent.id)
|
||||
.then(() => {
|
||||
setRegistry((r) => r && { ...r, agents: afterRemove(r.agents, agent.id) });
|
||||
onChanged?.();
|
||||
})
|
||||
.catch((e: unknown) => setError(agent.id, errorText(e)))
|
||||
.finally(() => setRemoving(null));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
<SearchField
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={tx("Search the ACP Registry")}
|
||||
aria-label="Search agents"
|
||||
clearable
|
||||
/>
|
||||
<p className={styles.notice}>
|
||||
<T>
|
||||
Agents in the ACP Registry are third-party software. They run on this computer with your permissions and can
|
||||
read and change your files. Install only agents you trust.
|
||||
</T>
|
||||
</p>
|
||||
{registry?.warning && (
|
||||
<p className={styles.warning} role="status">
|
||||
{registry.warning}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.list} aria-busy={!registry && !loadError}>
|
||||
{loadError ? (
|
||||
<p className={styles.error} role="alert">
|
||||
{tx`Cannot load the ACP Registry: ${loadError}`}
|
||||
</p>
|
||||
) : !registry ? (
|
||||
<div className={styles.loading}>
|
||||
<Spinner size={14} />
|
||||
<T>Loading the ACP Registry…</T>
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<p className={styles.empty}>
|
||||
{query ? <T>No agents match</T> : <T>The registry has no agents for this system</T>}
|
||||
</p>
|
||||
) : (
|
||||
<ul className={styles.rows} aria-label="Registry agents">
|
||||
{agents.map((agent) => (
|
||||
<AgentRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
job={job?.id === agent.id ? job : agent.installing ? backgroundJob(agent) : null}
|
||||
removing={removing === agent.id}
|
||||
disabled={busy}
|
||||
error={errors[agent.id] ?? null}
|
||||
onInstall={() => void install(agent)}
|
||||
onRemove={() => remove(agent)}
|
||||
onCancel={job?.id === agent.id ? () => backend.cancel(job.request) : undefined}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentRow({
|
||||
agent,
|
||||
job,
|
||||
removing,
|
||||
disabled,
|
||||
error,
|
||||
onInstall,
|
||||
onRemove,
|
||||
onCancel,
|
||||
}: {
|
||||
agent: RegistryAgent;
|
||||
job: Job | null;
|
||||
removing: boolean;
|
||||
disabled: boolean;
|
||||
error: string | null;
|
||||
onInstall: () => void;
|
||||
onRemove: () => void;
|
||||
/** Absent for an install this page did not start (stop it from the background tasks). */
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const actions = rowActions(agent);
|
||||
const titleId = `agent-${agent.id}`;
|
||||
return (
|
||||
<li className={styles.row} aria-labelledby={titleId}>
|
||||
<div className={styles.main}>
|
||||
<div className={styles.titleLine}>
|
||||
<Icon name={providerIcon(agent.id)} size={16} />
|
||||
<span id={titleId} className={styles.name}>
|
||||
{agent.name}
|
||||
</span>
|
||||
<span className={styles.version}>{agent.installed ?? agent.version}</span>
|
||||
{agent.distribution && <Badge>{DISTRIBUTION_LABEL[agent.distribution]}</Badge>}
|
||||
{agent.noChecksum && <Badge tone="warning">{tx("No checksum provided")}</Badge>}
|
||||
{actions.update && <Badge tone="accent">{tx`${agent.version} available`}</Badge>}
|
||||
{agent.installed != null && !actions.update && <Badge tone="success">{tx("Installed")}</Badge>}
|
||||
</div>
|
||||
{agent.description && <p className={styles.description}>{agent.description}</p>}
|
||||
<div className={styles.meta}>
|
||||
{agent.authors.length > 0 && <span>{agent.authors.join(", ")}</span>}
|
||||
{agent.license && <span>{agent.license}</span>}
|
||||
{agent.repository && <ExternalLink url={agent.repository} label="Repository" />}
|
||||
{agent.website && agent.website !== agent.repository && <ExternalLink url={agent.website} label="Website" />}
|
||||
</div>
|
||||
{job && (
|
||||
<div className={styles.progress}>
|
||||
<ProgressBar value={job.fraction} aria-label={tx`Installing ${agent.name}`} />
|
||||
<span className={styles.progressText}>{job.message}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
{job ? (
|
||||
onCancel && (
|
||||
<Button size="small" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{actions.install && (
|
||||
<Button size="small" variant="primary" disabled={disabled} onClick={onInstall}>
|
||||
Install
|
||||
</Button>
|
||||
)}
|
||||
{actions.update && (
|
||||
<Button size="small" variant="primary" disabled={disabled} onClick={onInstall}>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
{actions.remove && (
|
||||
<Button size="small" disabled={disabled} onClick={onRemove}>
|
||||
{removing ? "Removing…" : "Remove"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalLink({ url, label }: { url: string; label: string }) {
|
||||
// Links come from the registry: web pages only.
|
||||
if (!/^https?:\/\//i.test(url)) return null;
|
||||
return (
|
||||
<button type="button" className={styles.link} title={url} onClick={() => void openExternalUrl(url)}>
|
||||
<T>{label}</T>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export function ReplyActions({
|
||||
{task && reply && operations.fork && (
|
||||
<IconButton
|
||||
icon="branch"
|
||||
title="Branch Conversation from Here"
|
||||
title="Fork"
|
||||
size="small"
|
||||
action="agents.forkReply"
|
||||
actionArg={{ task: task.id, turn: reply.turn }}
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
}
|
||||
.age {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
padding-right: 8px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
@@ -186,12 +187,3 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 1px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { AgentTask } from "@/bindings";
|
||||
import * as actions from "@/platform/actions";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { EDITOR_TAB_MIME, type EditorInput } from "@/workbench/contributions";
|
||||
import { confirmChecked, showError } from "@/workbench/messages";
|
||||
import { ContextMenuHost, dragSession, hideContextMenu } from "@/ui";
|
||||
import { AgentsHeaderActions, AgentsLocationActions, AgentsView } from "./AgentsView";
|
||||
import { AgentsHeaderActions, AgentsView } from "./AgentsView";
|
||||
import { SESSION_KIND } from "./model";
|
||||
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, setSessionLocation, useAgents } from "./store";
|
||||
import { taskActions, taskMenu } from "./taskActions";
|
||||
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, useAgents } from "./store";
|
||||
import { taskActions, taskMenu, useAgentsHeaderMenu } from "./taskActions";
|
||||
import styles from "./Agents.module.css";
|
||||
|
||||
const editor = vi.hoisted(() => ({
|
||||
@@ -117,7 +117,6 @@ afterEach(() => {
|
||||
function setup() {
|
||||
render(
|
||||
<>
|
||||
<AgentsLocationActions />
|
||||
<AgentsHeaderActions />
|
||||
<AgentsView />
|
||||
<ContextMenuHost />
|
||||
@@ -166,7 +165,6 @@ describe("Agents docked view", () => {
|
||||
useAgents.setState({ docked: "one" });
|
||||
setup();
|
||||
expect(screen.getByTestId("docked-session")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Chat in Editor" })).toBeTruthy();
|
||||
expect(openTask).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("tree", { name: "Agent tasks" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
|
||||
@@ -209,15 +207,13 @@ describe("Agents docked view", () => {
|
||||
expect(screen.getByTestId("docked-session")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches placement from the global Agents header", () => {
|
||||
useAgents.setState({ docked: "one", sessionLocation: "toolWindow" });
|
||||
setup();
|
||||
expect(screen.queryByRole("button", { name: "Open in Editor" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Chat in Tool Window" })).toHaveAttribute("aria-pressed", "true");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Chat in Editor" }));
|
||||
expect(setSessionLocation).toHaveBeenCalledWith("editor");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Chat in Tool Window" }));
|
||||
expect(setSessionLocation).toHaveBeenCalledWith("toolWindow");
|
||||
it("offers placement in the Agents options menu, checked by the current one", () => {
|
||||
useAgents.setState({ sessionLocation: "toolWindow" });
|
||||
const { result } = renderHook(() => useAgentsHeaderMenu());
|
||||
expect(result.current).toEqual([
|
||||
{ kind: "action", action: "agents.useToolWindow", icon: "", checked: true },
|
||||
{ kind: "action", action: "agents.useEditor", icon: "", checked: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens tabs as usual without a docked conversation", () => {
|
||||
@@ -315,7 +311,9 @@ describe("Agents task menu", () => {
|
||||
const menu = taskMenu([useAgents.getState().tasks.get("one")!]);
|
||||
expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.dockTask" }));
|
||||
expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.undockTask" }));
|
||||
expect(menu).toContainEqual(expect.objectContaining({ action: "agents.openTask" }));
|
||||
// A click opens the task: the menu doesn't repeat it.
|
||||
expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.openTask" }));
|
||||
expect(menu[0]).toMatchObject({ action: "agents.renameTask" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,16 @@ import {
|
||||
showContextMenu,
|
||||
startPointerDrag,
|
||||
} from "@/ui";
|
||||
import { SESSION_KIND, TASK_DRAG, age, branchLabel, groupTasks, sessionKey, type TaskGroup } from "./model";
|
||||
import {
|
||||
SESSION_KIND,
|
||||
TASK_DRAG,
|
||||
age,
|
||||
branchLabel,
|
||||
groupTasks,
|
||||
providerIcon,
|
||||
sessionKey,
|
||||
type TaskGroup,
|
||||
} from "./model";
|
||||
import { openHome, useAgents } from "./store";
|
||||
import { taskMenu } from "./taskActions";
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
@@ -29,17 +38,6 @@ export function AgentsHeaderActions() {
|
||||
return <IconButton icon="plus" action="agents.newTask" />;
|
||||
}
|
||||
|
||||
export function AgentsLocationActions() {
|
||||
const location = useAgents((s) => s.sessionLocation);
|
||||
const tx = useTx();
|
||||
return (
|
||||
<div role="group" aria-label={tx("Conversation location")} className={styles.location}>
|
||||
<IconButton icon="open-in-tool-window" action="agents.useToolWindow" pressed={location === "toolWindow"} />
|
||||
<IconButton icon="editor-only" action="agents.useEditor" pressed={location === "editor"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Node = { kind: "group"; group: TaskGroup } | { kind: "task"; task: AgentTask };
|
||||
|
||||
const nodeKey = (n: Node) => (n.kind === "group" ? `g:${n.group.status}` : n.task.id);
|
||||
@@ -59,7 +57,7 @@ const sessionPayloadTask = (payload: string): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
export function TaskStatusIcon({ status }: { status: Status }) {
|
||||
function TaskStatusIcon({ status }: { status: Status }) {
|
||||
switch (status) {
|
||||
case "needsInput":
|
||||
return <Icon name="warning" size={13} label="Needs input" />;
|
||||
@@ -195,10 +193,12 @@ export function AgentsView() {
|
||||
if (inline && !e.metaKey && !e.ctrlKey && !e.shiftKey) showTask(n.task.id);
|
||||
}}
|
||||
>
|
||||
<TaskStatusIcon status={n.task.status} />
|
||||
<Icon name={providerIcon(n.task.provider)} size={16} label={providerName(n.task.provider)} />
|
||||
<span className={styles.title} data-unread={unread.has(n.task.id) || undefined}>
|
||||
{n.task.title}
|
||||
</span>
|
||||
{/* Ready tasks say so by their group; the others show what they're doing. */}
|
||||
{n.task.status !== "ready" && <TaskStatusIcon status={n.task.status} />}
|
||||
{unread.has(n.task.id) && (
|
||||
<span className={styles.unreadDot} role="img" aria-label={tx("Unread")} title={tx("Unread")} />
|
||||
)}
|
||||
@@ -207,10 +207,7 @@ export function AgentsView() {
|
||||
{basename(n.task.projectRoot ?? project.root)}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.detail}>
|
||||
{providerName(n.task.provider)}
|
||||
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
|
||||
</span>
|
||||
{n.task.isolation === "worktree" && <span className={styles.detail}>{branchLabel(n.task.branch)}</span>}
|
||||
<span className={styles.age}>{age(n.task.updatedMs ?? 0)}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
.split {
|
||||
display: flex;
|
||||
min-width: 640px;
|
||||
flex: none;
|
||||
min-height: 320px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-control);
|
||||
overflow: hidden;
|
||||
}
|
||||
.master {
|
||||
flex: 0 0 300px;
|
||||
padding: 6px;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.agentRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.agentMissing .agentName {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.agentMissing svg {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.tag {
|
||||
flex: none;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.tagCustom {
|
||||
color: var(--accent);
|
||||
}
|
||||
.agentName {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.title h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-input-large);
|
||||
}
|
||||
.subtitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
margin: 6px 0 20px 28px;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.dot {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--fg-subtle);
|
||||
}
|
||||
.dotFound {
|
||||
background: var(--success);
|
||||
}
|
||||
.dotCustom {
|
||||
background: var(--accent);
|
||||
}
|
||||
.launch {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.section {
|
||||
margin: 20px 0 6px;
|
||||
font-size: var(--fs-ui);
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.section + .hint {
|
||||
margin-top: 0;
|
||||
}
|
||||
.hint code {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--fg);
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.controls > :first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.hint {
|
||||
margin: 8px 0 16px;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
/** Built-in launch settings; each executable is staged independently until Apply. */
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { AgentProvider } from "@/bindings";
|
||||
import { tx, useTxTemplate } from "@/platform/i18n";
|
||||
import clsx from "clsx";
|
||||
import { Icon, IconButton, Input, List, Spinner, T } from "@/ui";
|
||||
import { useSettingsForm } from "@/workbench/contributions";
|
||||
import { providerIcon } from "./model";
|
||||
import type { CommandsBackend } from "./registry";
|
||||
import styles from "./BuiltinAgents.module.css";
|
||||
|
||||
/** Trailing list tag: a missing agent is a normal state, so it is muted rather than a warning. */
|
||||
function Status({ agent }: { agent: AgentProvider }) {
|
||||
if (agent.commandOverridden)
|
||||
return (
|
||||
<span className={clsx(styles.tag, styles.tagCustom)}>
|
||||
<T>Custom</T>
|
||||
</span>
|
||||
);
|
||||
if (agent.installed) return null;
|
||||
return (
|
||||
<span className={styles.tag}>
|
||||
<T>Not found</T>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function BuiltinAgents({ backend, onChanged }: { backend: CommandsBackend; onChanged?: () => void }) {
|
||||
const [agents, setAgents] = useState<AgentProvider[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState(0);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
backend.providers().then(
|
||||
(list) => live && setAgents(list.filter((p) => p.defaultProgram != null)),
|
||||
(e: unknown) => live && setError(e instanceof Error ? e.message : String(e)),
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [backend]);
|
||||
const refresh = useCallback(async () => {
|
||||
const list = await backend.providers();
|
||||
setAgents(list.filter((p) => p.defaultProgram != null));
|
||||
}, [backend]);
|
||||
if (error)
|
||||
return (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
if (!agents) return <Spinner size={14} />;
|
||||
const agent = agents[selected] ?? agents[0];
|
||||
return (
|
||||
<div className={styles.split}>
|
||||
<List
|
||||
className={styles.master}
|
||||
items={agents}
|
||||
getKey={(p) => p.id}
|
||||
activeIndex={selected}
|
||||
onActiveChange={setSelected}
|
||||
rowHeight={32}
|
||||
aria-label={tx("Built-in agents")}
|
||||
renderItem={(p) => (
|
||||
<div className={clsx(styles.agentRow, !p.installed && !p.commandOverridden && styles.agentMissing)}>
|
||||
<Icon name={providerIcon(p.id)} size={16} />
|
||||
<span className={styles.agentName}>{p.name}</span> <Status agent={p} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{agent && <Details key={agent.id} agent={agent} backend={backend} refresh={refresh} onChanged={onChanged} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Details({
|
||||
agent,
|
||||
backend,
|
||||
refresh,
|
||||
onChanged,
|
||||
}: {
|
||||
agent: AgentProvider;
|
||||
backend: CommandsBackend;
|
||||
refresh: () => Promise<void>;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const form = useSettingsForm();
|
||||
const base = agent.commandOverridden ? agent.program : "";
|
||||
const { value, current, set, saving } = form.useExternal(`agents.${agent.id}.command`, base, async (draft) => {
|
||||
const command = draft.trim() || null;
|
||||
await backend.set(agent.id, command);
|
||||
await refresh();
|
||||
onChanged?.();
|
||||
return command === agent.defaultProgram ? "" : (command ?? "");
|
||||
});
|
||||
const envBase = formatEnv(agent.env);
|
||||
const env = form.useExternal(`agents.${agent.id}.env`, envBase, async (draft) => {
|
||||
const parsed = parseEnv(draft);
|
||||
await backend.setEnv(agent.id, parsed);
|
||||
await refresh();
|
||||
onChanged?.();
|
||||
return formatEnv(parsed);
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// An edit can have been staged by a previous page instance. Its Apply callback
|
||||
// refreshes that instance; the mounted one follows the newly applied value too.
|
||||
useEffect(() => {
|
||||
if (!saving && current !== base) {
|
||||
void refresh().catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)));
|
||||
}
|
||||
}, [base, current, saving, refresh]);
|
||||
const launch = [agent.defaultProgram, ...agent.defaultArgs].join(" ");
|
||||
const status = agent.commandOverridden
|
||||
? tx("Custom executable")
|
||||
: agent.installed
|
||||
? tx("Found on PATH")
|
||||
: tx("Not found on PATH");
|
||||
return (
|
||||
<section className={styles.details} aria-label={tx`${agent.name} executable`}>
|
||||
<div className={styles.title}>
|
||||
<Icon name={providerIcon(agent.id)} size={20} />
|
||||
<h3>{agent.name}</h3>
|
||||
</div>
|
||||
<div className={styles.subtitle}>
|
||||
<span
|
||||
aria-hidden
|
||||
className={clsx(styles.dot, agent.commandOverridden ? styles.dotCustom : agent.installed && styles.dotFound)}
|
||||
/>
|
||||
<span>{status}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<code className={styles.launch} title={tx("Launch command")}>
|
||||
{launch}
|
||||
</code>
|
||||
</div>
|
||||
{agent.commandEditable ? (
|
||||
<>
|
||||
<label className={styles.label} htmlFor={`agent-executable-${agent.id}`}>
|
||||
<T>Executable</T>
|
||||
</label>
|
||||
<div className={styles.controls}>
|
||||
<Input
|
||||
id={`agent-executable-${agent.id}`}
|
||||
mono
|
||||
value={value}
|
||||
disabled={saving}
|
||||
placeholder={tx`Auto-detect (${agent.defaultProgram})`}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="folder-open"
|
||||
title="Browse…"
|
||||
disabled={saving}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const path = await backend.chooseExecutable(tx`Select ${agent.name}`, value);
|
||||
if (path) set(path);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{value && <IconButton icon="undo" title="Reset" disabled={saving} onClick={() => set("")} />}
|
||||
</div>
|
||||
<p className={styles.hint}>
|
||||
<T>Leave empty to find the executable on PATH. Changes apply to new sessions.</T>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className={styles.hint}>
|
||||
{agent.commandReadOnlyReason === "packageRunner" ? (
|
||||
<T>This agent runs through a package runner (npm or uv) and has no executable to choose.</T>
|
||||
) : agent.commandReadOnlyReason === "registry" ? (
|
||||
<T>This agent is installed from the ACP Registry. Manage its installation in the ACP Registry tab.</T>
|
||||
) : (
|
||||
<T>This agent is configured in its agent file.</T>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{agent.commandReadOnlyReason !== "registry" && agent.commandReadOnlyReason !== "customAgent" && (
|
||||
<>
|
||||
<label className={styles.label} htmlFor={`agent-env-${agent.id}`}>
|
||||
<T>Environment variables</T>
|
||||
</label>
|
||||
<div className={styles.controls}>
|
||||
<Input
|
||||
id={`agent-env-${agent.id}`}
|
||||
mono
|
||||
value={env.value}
|
||||
disabled={env.saving}
|
||||
placeholder="NAME=value; OTHER=value"
|
||||
onChange={(e) => env.set(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p className={styles.hint}>
|
||||
<T>Passed to the agent process, replacing variables of the same name. Changes apply to new sessions.</T>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{agent.loginHint && (
|
||||
<>
|
||||
<h4 className={styles.section}>
|
||||
<T>Sign in</T>
|
||||
</h4>
|
||||
<p className={styles.hint}>
|
||||
<LoginHint text={agent.loginHint} />
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** `NAME=value` pairs separated by `;` or new lines. */
|
||||
function parseEnv(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const raw of text.split(/[;\n]/)) {
|
||||
const pair = raw.trim();
|
||||
if (!pair) continue;
|
||||
const eq = pair.indexOf("=");
|
||||
const key = eq < 0 ? "" : pair.slice(0, eq).trim();
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(tx`Expected NAME=value: ${pair}`);
|
||||
env[key] = pair.slice(eq + 1).trim();
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const formatEnv = (env: Record<string, string>) =>
|
||||
Object.entries(env)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
|
||||
/** Login hints are plain text with optional backtick-delimited command spans. */
|
||||
function LoginHint({ text }: { text: string }) {
|
||||
const translate = useTxTemplate();
|
||||
return translate(text)
|
||||
.split(/(`[^`]+`)/g)
|
||||
.map((part, i) => (i % 2 === 1 ? <code key={i}>{part.slice(1, -1)}</code> : part));
|
||||
}
|
||||
@@ -99,15 +99,10 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
/* Attach/reference buttons share the send button's 24px footprint and chrome. */
|
||||
/* Plain toolbar icons, framed only on hover like the rest of the IDE's toolbars. */
|
||||
.composerButton {
|
||||
width: var(--h-control);
|
||||
height: var(--h-control);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-elev);
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
}
|
||||
.composerButton:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
@@ -320,6 +315,10 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The effort is in the model popup: the button keeps the model's name readable. */
|
||||
.chipDetail {
|
||||
display: none;
|
||||
}
|
||||
.inlineSetting {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import type { AgentCommand, AgentConfigOption, AgentImage } from "@/bindings";
|
||||
import type { AgentCommand, AgentConfigOption, AgentImage, SendKey, WhileRunning } from "@/bindings";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { nativeDropPoint } from "@/platform/nativeDrop";
|
||||
import { invokeLocal } from "@/platform/tauriCore";
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
import { composerDraft, updateComposer, useAgents } from "./store";
|
||||
import { ImagePreview, imageSrc } from "./ImagePreview";
|
||||
import { useCompletionAnchor } from "./useCompletionAnchor";
|
||||
import { useAgentSettings } from "./agentSettings";
|
||||
import styles from "./Composer.module.css";
|
||||
import { isImeKey } from "@/platform/ime";
|
||||
|
||||
@@ -84,7 +85,7 @@ export interface ComposerProps {
|
||||
/** Settings; Shift+Tab cycles the mode one. */
|
||||
config: readonly AgentConfigOption[];
|
||||
onConfig?: (option: string, value: string) => void;
|
||||
/** `now`: ⌘↵ (send even while the agent works, interrupting it). */
|
||||
/** `now`: send even while the agent works, into its turn (by default ⌘↵). */
|
||||
onSubmit: (now: boolean) => void;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
@@ -95,6 +96,29 @@ export interface ComposerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a key press in the composer sends: "queue" waits for the turn to end, "now" goes into
|
||||
* it. The send key sends the way Settings › Agents › Chat says, with Shift (or the
|
||||
* modifier, when Enter sends) the other way; without the send key, Enter is a new line.
|
||||
*/
|
||||
export function sendKeyAction(
|
||||
e: { key: string; shiftKey: boolean; metaKey: boolean; ctrlKey: boolean; altKey?: boolean },
|
||||
sendWith: SendKey,
|
||||
whileRunning: WhileRunning,
|
||||
): "queue" | "now" | null {
|
||||
if (e.key !== "Enter" || e.altKey) return null;
|
||||
const mod = e.metaKey || e.ctrlKey;
|
||||
let other: boolean;
|
||||
if (sendWith === "enter") {
|
||||
if (e.shiftKey) return null;
|
||||
other = mod;
|
||||
} else {
|
||||
if (!mod) return null;
|
||||
other = e.shiftKey;
|
||||
}
|
||||
return (whileRunning === "steer") !== other ? "now" : "queue";
|
||||
}
|
||||
|
||||
export function Composer(props: ComposerProps) {
|
||||
const { id, root, projectRoot } = props;
|
||||
const tx = useTx();
|
||||
@@ -112,6 +136,7 @@ export function Composer(props: ComposerProps) {
|
||||
const [active, setActive] = useState(0);
|
||||
const composing = useRef(false);
|
||||
const recalled = useRef<{ index: number; stash: string }>({ index: -1, stash: "" });
|
||||
const { sendWith, whileRunning } = useAgentSettings();
|
||||
|
||||
useEffect(() => {
|
||||
if (props.autoFocus) ref.current?.focus();
|
||||
@@ -424,9 +449,10 @@ export function Composer(props: ComposerProps) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
const send = sendKeyAction(e, sendWith, whileRunning);
|
||||
if (send) {
|
||||
e.preventDefault();
|
||||
if (!props.disabled) props.onSubmit(e.metaKey || e.ctrlKey);
|
||||
if (!props.disabled) props.onSubmit(send === "now");
|
||||
recalled.current = { index: -1, stash: "" };
|
||||
return;
|
||||
}
|
||||
@@ -600,7 +626,7 @@ export function Composer(props: ComposerProps) {
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon="file-image"
|
||||
icon="image"
|
||||
title="Attach Image…"
|
||||
className={styles.composerButton}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
useAgentCommands,
|
||||
useAgents,
|
||||
} from "./store";
|
||||
import { useAgentSettings } from "./agentSettings";
|
||||
import styles from "./Home.module.css";
|
||||
|
||||
export function HomeEditor({ docked = false }: { docked?: boolean; input?: EditorInput }) {
|
||||
@@ -95,7 +96,8 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setStarting(false));
|
||||
};
|
||||
const config = draftConfig(provider, draft);
|
||||
const defaults = useAgentSettings().defaults;
|
||||
const config = draftConfig(provider, draft, (provider && defaults[provider.id]) || {});
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.home, docked && styles.docked)}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks";
|
||||
import { resetMermaidForTests } from "../viewers/markdown/mermaid";
|
||||
import { StrictMode } from "react";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { Markdown, markdownBlocks, markdownHtml } from "./Markdown";
|
||||
|
||||
const { openFile, openExternalUrl } = vi.hoisted(() => ({
|
||||
openFile: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -12,12 +12,19 @@ const { openFile, openExternalUrl } = vi.hoisted(() => ({
|
||||
const copyText = vi.hoisted(() => vi.fn());
|
||||
const readImage = vi.fn<(path: string) => Promise<string>>();
|
||||
const PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
vi.mock("@/workbench/contributions", () => ({ editorService: () => ({ openFile }) }));
|
||||
vi.mock("@/workbench/contributions", () => ({
|
||||
editorService: () => ({ openFile }),
|
||||
}));
|
||||
vi.mock("@/platform/openUrl", () => ({ openExternalUrl }));
|
||||
vi.mock("@/ui", async (importOriginal) => ({ ...(await importOriginal<typeof import("@/ui")>()), copyText }));
|
||||
vi.mock("@/ui", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/ui")>()),
|
||||
copyText,
|
||||
}));
|
||||
|
||||
const mermaid = vi.hoisted(() => ({ initialize: vi.fn(), render: vi.fn() }));
|
||||
vi.mock("mermaid", () => ({ default: { initialize: mermaid.initialize, render: mermaid.render } }));
|
||||
vi.mock("mermaid", () => ({
|
||||
default: { initialize: mermaid.initialize, render: mermaid.render },
|
||||
}));
|
||||
|
||||
const DIAGRAM = "flowchart TD\n A-->B";
|
||||
const FENCE = `\`\`\`mermaid\n${DIAGRAM}\n\`\`\`\n`;
|
||||
@@ -170,7 +177,11 @@ it("reuses image reads while a reply streams, but reads again for another messag
|
||||
});
|
||||
|
||||
it("reports read failure and retries on the next render", async () => {
|
||||
readImage.mockRejectedValueOnce({ kind: "io", message: "missing", data: null });
|
||||
readImage.mockRejectedValueOnce({
|
||||
kind: "io",
|
||||
message: "missing",
|
||||
data: null,
|
||||
});
|
||||
const { rerender } = render(<Markdown text="" cwd="/repo" />);
|
||||
await waitFor(() => expect(screen.getByRole("img")).toHaveAttribute("alt", "Unable to load image: /tmp/missing.png"));
|
||||
expect(screen.getByRole("img")).not.toHaveAttribute("src");
|
||||
@@ -274,3 +285,43 @@ describe("mermaid diagrams in a reply", () => {
|
||||
expect(onDiagramDrawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("streamed replies", () => {
|
||||
it("cuts a reply into blocks that join back into it", () => {
|
||||
const text = "# Plan\n\nFirst *one*.\n\n- a\n- b\n\n```ts\nconst a = 1;\n```\n\nTail";
|
||||
const blocks = markdownBlocks(text);
|
||||
expect(blocks.join("")).toBe(text);
|
||||
expect(blocks).toHaveLength(5);
|
||||
expect(blocks.map((b) => markdownHtml(b)).join("")).toBe(markdownHtml(text));
|
||||
expect(markdownBlocks("See [docs][d].\n\n[d]: https://example.com")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders an HTML block that wraps Markdown whole", () => {
|
||||
const text = "<details>\n<summary>Log</summary>\n\ntext\n\n</details>";
|
||||
expect(markdownBlocks(text)).toEqual([text]);
|
||||
const { container } = render(<Markdown text={text} cwd="/repo" />);
|
||||
expect(container.querySelector("details p")?.textContent).toBe("text");
|
||||
});
|
||||
|
||||
it("cuts only what streamed on after the blocks that ended", () => {
|
||||
const before = markdownBlocks("One.\n\nTwo.\n\nThr");
|
||||
const after = markdownBlocks("One.\n\nTwo.\n\nThree.\n\n- a", before);
|
||||
expect(after).toEqual(["One.\n\n", "Two.\n\n", "Three.\n\n", "- a"]);
|
||||
expect(after[0]).toBe(before[0]);
|
||||
expect(markdownBlocks("Other.\n\nTwo.\n\nThree.", before)).toEqual(["Other.\n\n", "Two.\n\n", "Three."]);
|
||||
});
|
||||
|
||||
it("keeps the blocks a chunk didn't change", () => {
|
||||
const head = "Intro paragraph.\n\n```ts\nconst a = 1;\n```\n\n";
|
||||
const { container, rerender } = render(<Markdown text={`${head}Half a sen`} cwd="/repo" />);
|
||||
const intro = container.querySelector("p")!;
|
||||
const code = container.querySelector("pre")!;
|
||||
rerender(<Markdown text={`${head}Half a sentence, then more.`} cwd="/repo" />);
|
||||
expect(container.querySelector("p")).toBe(intro);
|
||||
expect(container.querySelector("pre")).toBe(code);
|
||||
expect(container.querySelectorAll("p")[1]!.textContent).toBe("Half a sentence, then more.");
|
||||
expect(screen.getAllByRole("button", { name: "Copy Code" })).toHaveLength(1);
|
||||
rerender(<Markdown text="Something else" cwd="/repo" />);
|
||||
expect(container.textContent?.trim()).toBe("Something else");
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
* the drawn diagram hides the code block, and `onDiagramDrawn` lets the conversation
|
||||
* follow the end when a diagram draws after the last streamed chunk.
|
||||
*/
|
||||
import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
||||
import { useLayoutEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { Marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { highlightInto } from "@/editor/lsp/markdown";
|
||||
@@ -60,6 +60,40 @@ function purify(): ReturnType<typeof DOMPurify> {
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* `text` cut into its top-level blocks (a paragraph, a list, a fence…), each with the
|
||||
* blank lines after it, so they join back into `text`. While a reply streams only its
|
||||
* last block changes: the others keep their rendering. Reference links reach across
|
||||
* blocks, and an HTML block can open in one token and close in another, so a text with
|
||||
* either stays whole. The blocks before the last of `previous` (this text's blocks
|
||||
* before it streamed on) have ended: when `text` still starts with them, only the rest
|
||||
* is cut again.
|
||||
*/
|
||||
export function markdownBlocks(text: string, previous: readonly string[] = []): string[] {
|
||||
let stable = 0;
|
||||
let offset = 0;
|
||||
while (stable < previous.length - 1 && text.startsWith(previous[stable]!, offset))
|
||||
offset += previous[stable++]!.length;
|
||||
if (stable) {
|
||||
const rest = lexBlocks(text.slice(offset));
|
||||
if (rest) return [...previous.slice(0, stable), ...rest];
|
||||
}
|
||||
return lexBlocks(text) ?? [text];
|
||||
}
|
||||
|
||||
/** `text`'s blocks, or `null` when it has to render whole. */
|
||||
function lexBlocks(text: string): string[] | null {
|
||||
const tokens = marked.lexer(text);
|
||||
if (Object.keys(tokens.links).length || tokens.some((t) => t.type === "html")) return null;
|
||||
const blocks: string[] = [];
|
||||
for (const t of tokens) {
|
||||
if (t.type === "space" && blocks.length) blocks[blocks.length - 1] += t.raw;
|
||||
else blocks.push(t.raw);
|
||||
}
|
||||
// The lexer can drop what it doesn't keep in a token (trailing whitespace); keep the text whole.
|
||||
return blocks.join("") === text ? blocks : null;
|
||||
}
|
||||
|
||||
export function markdownHtml(text: string): string {
|
||||
let html = rendered.get(text);
|
||||
if (html === undefined) {
|
||||
@@ -184,6 +218,48 @@ function enhance(root: HTMLElement, cwd: string, images: Map<string, Promise<str
|
||||
};
|
||||
}
|
||||
|
||||
interface PaintedBlock {
|
||||
source: string;
|
||||
nodes: ChildNode[];
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
/** Removes the painted blocks from `from` on. */
|
||||
function clearBlocks(painted: PaintedBlock[], from: number) {
|
||||
for (const block of painted.splice(from)) {
|
||||
block.dispose();
|
||||
block.nodes.forEach((n) => n.remove());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings `root` to `sources`: blocks whose source is unchanged keep their nodes (and
|
||||
* whatever was drawn into them); the rest are rendered after them. The nodes go straight
|
||||
* into `root`, as a single rendering would put them, so styles and selections see one
|
||||
* message.
|
||||
*/
|
||||
function paintBlocks(
|
||||
root: HTMLElement,
|
||||
painted: PaintedBlock[],
|
||||
sources: string[],
|
||||
cwd: string,
|
||||
images: Map<string, Promise<string>>,
|
||||
) {
|
||||
let same = 0;
|
||||
while (same < painted.length && same < sources.length && painted[same]!.source === sources[same]) same++;
|
||||
clearBlocks(painted, same);
|
||||
sources.slice(same).forEach((source, i, rest) => {
|
||||
const holder = document.createElement("div");
|
||||
// A streaming reply changes its last block on every frame: kept, those would push
|
||||
// the finished blocks of other messages out of the cache.
|
||||
holder.innerHTML = i === rest.length - 1 && !rendered.has(source) ? renderHtml(source) : markdownHtml(source);
|
||||
const dispose = enhance(holder, cwd, images);
|
||||
const nodes = [...holder.childNodes];
|
||||
root.append(...nodes);
|
||||
painted.push({ source, nodes, dispose });
|
||||
});
|
||||
}
|
||||
|
||||
export function Markdown({
|
||||
text,
|
||||
cwd,
|
||||
@@ -194,14 +270,32 @@ export function Markdown({
|
||||
/** A diagram drew after the message rendered: the conversation may follow the end. */
|
||||
onDiagramDrawn?: () => void;
|
||||
}) {
|
||||
const html = useMemo(() => markdownHtml(text), [text]);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const images = useRef(new Map<string, Promise<string>>());
|
||||
/** The blocks on screen, so a streamed chunk repaints only the blocks it changed. */
|
||||
const painted = useRef<PaintedBlock[]>([]);
|
||||
const paintedCwd = useRef(cwd);
|
||||
/** The last drawing of each mermaid block, stood in while a changed one renders. */
|
||||
const diagrams = useRef<Node[]>([]);
|
||||
// Diagrams are drawn in the active theme's colours: a theme change redraws them.
|
||||
const theme = useSyncExternalStore(subscribeTheme, themeKey, () => "");
|
||||
useLayoutEffect(() => (ref.current ? enhance(ref.current, cwd, images.current) : undefined), [html, cwd]);
|
||||
useLayoutEffect(() => {
|
||||
const root = ref.current;
|
||||
if (!root) return;
|
||||
if (paintedCwd.current !== cwd) {
|
||||
clearBlocks(painted.current, 0);
|
||||
paintedCwd.current = cwd;
|
||||
}
|
||||
const sources = markdownBlocks(
|
||||
text,
|
||||
painted.current.map((b) => b.source),
|
||||
);
|
||||
paintBlocks(root, painted.current, sources, cwd, images.current);
|
||||
}, [text, cwd]);
|
||||
useLayoutEffect(() => {
|
||||
const blocks = painted.current;
|
||||
return () => clearBlocks(blocks, 0);
|
||||
}, []);
|
||||
// Mermaid diagrams: before paint, so cached ones don't flash their source. Only a
|
||||
// draw that lands after the render needs the follow — cached ones are in place by
|
||||
// the time the conversation's own render pass scrolls.
|
||||
@@ -215,7 +309,7 @@ export function Markdown({
|
||||
live = false;
|
||||
pass.cancel();
|
||||
};
|
||||
}, [html, theme, onDiagramDrawn]);
|
||||
}, [text, theme, onDiagramDrawn]);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -244,7 +338,6 @@ export function Markdown({
|
||||
if (link) openPathLink(link);
|
||||
}
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -34,38 +34,11 @@
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 6px 0 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.header > * {
|
||||
flex: none;
|
||||
}
|
||||
/* Shrinks ahead of the meters. */
|
||||
.title {
|
||||
flex: 0 1000 auto;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.header > .headerSpacer {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
.providerIcon {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
/* The task's state, just above the composer; the header keeps only the actions. */
|
||||
/* The task's state and its terminal and changes, just above the composer. */
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -75,6 +48,16 @@
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.statusActions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.statusActions > .changesToggle {
|
||||
height: var(--h-control-small);
|
||||
padding: 0 4px;
|
||||
}
|
||||
.statusText {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
@@ -191,8 +174,7 @@
|
||||
width: fit-content;
|
||||
max-width: min(var(--session-card-width), 720px);
|
||||
}
|
||||
.userMessage > .messageHead,
|
||||
.userMessage > .messageFooter {
|
||||
.userMessage > .messageHead {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
/* The actions and time below a message, in flow so they never cover the next entry. */
|
||||
@@ -257,15 +239,18 @@
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.messageFooter > .messageTime {
|
||||
.messageFooter > .messageTime,
|
||||
.messageHead > .messageTime {
|
||||
opacity: 0;
|
||||
}
|
||||
.conversationRow:nth-last-child(1 of [data-message]) .messageFooter > .messageTime,
|
||||
.message:hover .messageFooter > .messageTime,
|
||||
/* A turn's final reply keeps its time and actions in view; your messages show theirs on hover. */
|
||||
.conversationRow:nth-last-child(1 of [data-message]) .agentMessage .messageTime,
|
||||
.conversationRow:nth-last-child(1 of [data-message]) .agentMessage .messageActions,
|
||||
.conversationRow:nth-last-child(1 of [data-message]) .reply .messageActions,
|
||||
.message:hover .messageTime,
|
||||
.agentMessage:hover .messageFooter > .messageTime,
|
||||
.message:focus-within .messageFooter > .messageTime,
|
||||
.message:focus-within .messageTime,
|
||||
.agentMessage:focus-within .messageFooter > .messageTime,
|
||||
.conversationRow:nth-last-child(1 of [data-message]) .messageActions,
|
||||
.message:hover .messageActions,
|
||||
.reply:hover .messageActions,
|
||||
.agentMessage:hover .messageActions,
|
||||
@@ -380,18 +365,23 @@
|
||||
.markdown :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown {
|
||||
/* A tint of the text colour: reads as code on any background, and a selection over
|
||||
it doesn't leave light boxes the way an opaque near-white fill does. */
|
||||
--md-code-bg: color-mix(in srgb, var(--fg) 7%, transparent);
|
||||
}
|
||||
.markdown :global(code) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
padding: 0 4px;
|
||||
padding: 0 2px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-elev);
|
||||
background: var(--md-code-bg);
|
||||
}
|
||||
.markdown :global(pre) {
|
||||
margin: 0 0 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-elev);
|
||||
background: var(--md-code-bg);
|
||||
overflow: auto;
|
||||
}
|
||||
.markdown :global(pre code) {
|
||||
@@ -449,6 +439,9 @@
|
||||
.markdown :global(.mermaid-output) {
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
/* Node labels are drawing, not text: selecting across a reply skips them. */
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.markdown :global([data-mermaid-state="error"] > .mermaid-output) {
|
||||
margin-top: 6px;
|
||||
@@ -1471,3 +1464,87 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Reasoning while it streams (auto): bounded, its tail in view. */
|
||||
.thoughtLive .thoughtBody {
|
||||
max-height: calc(var(--h-row) * 4);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
mask-image: linear-gradient(to bottom, transparent, black 40%);
|
||||
}
|
||||
|
||||
/* A finished turn's work, folded under its message. */
|
||||
.turnFold > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.turnFold > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.turnFold[open] .processChevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.turnBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-top: 2px;
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Above the composer: what waits for the user, the plan and changes, what runs now. */
|
||||
.tray {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.tray:empty {
|
||||
display: none;
|
||||
}
|
||||
.trayAsk {
|
||||
min-width: 0;
|
||||
}
|
||||
.trayStrip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.trayChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: var(--h-row);
|
||||
padding: 0 var(--space-2);
|
||||
border: none;
|
||||
border-radius: var(--radius-row);
|
||||
background: none;
|
||||
color: var(--fg-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--fs-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.trayChip:hover,
|
||||
.trayChip[aria-pressed="true"],
|
||||
.trayChip[aria-expanded="true"] {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.trayPlan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 2px var(--space-2) 4px calc(var(--space-2) * 2);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.planCurrent {
|
||||
color: var(--fg);
|
||||
}
|
||||
.trayActivity {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
} from "@/ui";
|
||||
import { useTx, tx } from "@/platform/i18n";
|
||||
import { isImeKey } from "@/platform/ime";
|
||||
import { TaskStatusIcon } from "./AgentsView";
|
||||
import { Composer } from "./Composer";
|
||||
import { SubmitButton } from "./SubmitButton";
|
||||
import {
|
||||
@@ -72,6 +71,9 @@ import {
|
||||
import { ConfigPickers } from "./ConfigPickers";
|
||||
import { findRanges, formatDuration, formatSentAt } from "./conversation";
|
||||
import { ImagePreview, imageSrc } from "./ImagePreview";
|
||||
import { useAgentSettings } from "./agentSettings";
|
||||
import { currentTool, foldedTurns, summarizeWork, workParts, type FoldedTurn, type WorkSummary } from "./timeline";
|
||||
import { useRevealedText } from "./reveal";
|
||||
import { AgentReply, MessageActions, ReplyActions } from "./AgentReply";
|
||||
import {
|
||||
commandPrefix,
|
||||
@@ -187,13 +189,6 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
}}
|
||||
>
|
||||
<div className={styles.conversation} hidden={docked && changesOpen}>
|
||||
<Header
|
||||
task={task}
|
||||
changes={changes ?? null}
|
||||
changesOpen={changesOpen}
|
||||
onToggleChanges={() => setChangesOpen(!changesOpen)}
|
||||
docked={docked}
|
||||
/>
|
||||
{view && <SubagentBar task={task} path={path} cards={cards} onNavigate={setPath} />}
|
||||
<FoldedContent.Provider value={finding && shown}>
|
||||
<Stream
|
||||
@@ -215,7 +210,15 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
) : (
|
||||
<>
|
||||
<QuestionCard task={task} entries={entries ?? null} />
|
||||
<SessionComposer task={task} entries={entries ?? null} head={head} onOpenSubagent={openSubagent} />
|
||||
<SessionComposer
|
||||
task={task}
|
||||
entries={entries ?? null}
|
||||
head={head}
|
||||
changes={changes ?? null}
|
||||
changesOpen={changesOpen}
|
||||
onToggleChanges={() => setChangesOpen(!changesOpen)}
|
||||
onOpenSubagent={openSubagent}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -261,45 +264,28 @@ function useLocalHead(status: AgentTask["status"] | null, root: string | null):
|
||||
return local ? head : null;
|
||||
}
|
||||
|
||||
function Header({
|
||||
/** The task's terminal and changes, at the end of its status line. */
|
||||
function StatusActions({
|
||||
task,
|
||||
changes,
|
||||
changesOpen,
|
||||
onToggleChanges,
|
||||
docked,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
changes: AgentChange[] | null;
|
||||
changesOpen: boolean;
|
||||
onToggleChanges: () => void;
|
||||
/** The tool window bar above a docked conversation already shows the title. */
|
||||
docked?: boolean;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const count = changes?.length ?? 0;
|
||||
return (
|
||||
<div
|
||||
className={styles.header}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e, taskMenu([task]));
|
||||
}}
|
||||
>
|
||||
<TaskStatusIcon status={task.status} />
|
||||
{!docked && (
|
||||
<span className={styles.title} title={task.title}>
|
||||
{task.title}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.headerSpacer} />
|
||||
<span className={styles.statusActions}>
|
||||
<IconButton
|
||||
size="small"
|
||||
icon="terminal"
|
||||
title="Open in Terminal"
|
||||
title="Terminal"
|
||||
onClick={() => void executeAction("terminal.openAt", task.cwd)}
|
||||
/>
|
||||
{task.status === "archived" && (
|
||||
<IconButton icon="undo" title="Unarchive Task" action="agents.unarchiveTasks" actionArg={[task.id]} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.changesToggle}
|
||||
@@ -312,7 +298,7 @@ function Header({
|
||||
<Icon name="toolwindow-changes" size={14} />
|
||||
{count > 0 && <span>{count}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -321,21 +307,35 @@ function SessionStatus({
|
||||
task,
|
||||
entries,
|
||||
head,
|
||||
changes,
|
||||
changesOpen,
|
||||
onToggleChanges,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
head: string | null;
|
||||
changes: AgentChange[] | null;
|
||||
changesOpen: boolean;
|
||||
onToggleChanges: () => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const project = useProject((s) => s.project);
|
||||
const projectName = project?.workspace ? basename(task.projectRoot ?? project.root) : null;
|
||||
const where = task.isolation === "worktree" ? branchLabel(task.branch) : null;
|
||||
const pickedModel = task.status !== "archived" && task.config.some((o) => o.category === "model");
|
||||
return (
|
||||
<div className={styles.statusBar}>
|
||||
<div
|
||||
className={styles.statusBar}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e, taskMenu([task]));
|
||||
}}
|
||||
>
|
||||
<Icon name={providerIcon(task.provider)} size={13} className={styles.providerIcon} />
|
||||
<span className={styles.statusText} title={task.cwd}>
|
||||
{providerName(task.provider)}
|
||||
{task.model ? ` · ${task.model}` : ""}
|
||||
{/* The composer's model picker shows the model when the agent offers one. */}
|
||||
{task.model && !pickedModel ? ` · ${task.model}` : ""}
|
||||
{projectName ? ` · ${projectName}` : ""}
|
||||
{" · "}
|
||||
{where ?? (
|
||||
@@ -349,13 +349,14 @@ function SessionStatus({
|
||||
{task.status === "limited" && task.resetsMs != null && ` · ${tx`resets ${formatSentAt(task.resetsMs)}`}`}
|
||||
</span>
|
||||
<Meters task={task} entries={entries} />
|
||||
<StatusActions task={task} changes={changes} changesOpen={changesOpen} onToggleChanges={onToggleChanges} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Context use, usage limits and cost (when the agent reports them) and the time spent on
|
||||
* the task, each as Settings › Tools › Agents › Chat allows.
|
||||
* the task, each as Settings › Agents › Chat allows.
|
||||
*/
|
||||
function Meters({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) {
|
||||
const tx = useTx();
|
||||
@@ -674,8 +675,6 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
setAway(false);
|
||||
el.focus({ preventScroll: true });
|
||||
}, []);
|
||||
const waiting = entries?.some((e) => e.type === "permission" && !e.chosen);
|
||||
const lastUser = entries?.findLast((e): e is UserEntry => e.type === "user");
|
||||
// Forks and undo apply to the task's own turns, not a subagent's conversation.
|
||||
const turns = useMemo(() => (entries && !subagent ? turnNumbers(entries) : null), [entries, subagent]);
|
||||
const replies = useMemo(() => (entries && !subagent ? replyTurns(entries) : null), [entries, subagent]);
|
||||
@@ -683,11 +682,16 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
const busy = subagent ? subagentWorking(subagent.status) : isBusy(task.status);
|
||||
const lastIndex = entries ? entries.length - 1 : -1;
|
||||
// Keep the row stable when earlier history is prepended; its author can move with the work.
|
||||
const rows = useMemo(() => (entries ? conversationRows(entries, start) : []), [entries, start]);
|
||||
const { foldTurns, toolCalls } = useAgentSettings();
|
||||
const rows = useMemo(() => {
|
||||
if (!entries) return [];
|
||||
const rows = conversationRows(entries, start, toolCalls === "grouped");
|
||||
return foldTurns && !finding ? foldTurnRows(rows, foldedTurns(entries, start, busy)) : rows;
|
||||
}, [entries, start, busy, foldTurns, toolCalls, finding]);
|
||||
const authorHeaders = useMemo(() => {
|
||||
const headers = new Set<number>();
|
||||
const byAgent = (row: ConversationRow) =>
|
||||
row.kind === "process" ||
|
||||
row.kind !== "entry" ||
|
||||
(row.entry.type !== "user" &&
|
||||
row.entry.type !== "subagent" &&
|
||||
!(row.entry.type === "message" && row.entry.incoming));
|
||||
@@ -714,6 +718,20 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
),
|
||||
[task, turns, replies, times, lastIndex, busy, onOpenSubagent, onDiagramDrawn],
|
||||
);
|
||||
const renderConversationRow = (row: ConversationRow): ReactNode =>
|
||||
row.kind === "turn" ? (
|
||||
<TurnFold row={row} entries={entries ?? []}>
|
||||
{row.rows.map((inner) => (
|
||||
<div key={inner.index} className={styles.conversationRow}>
|
||||
{renderConversationRow(inner)}
|
||||
</div>
|
||||
))}
|
||||
</TurnFold>
|
||||
) : row.kind === "process" ? (
|
||||
<ProcessRun row={row} task={task} lastIndex={lastIndex} busy={busy} renderRow={renderRow} />
|
||||
) : (
|
||||
renderRow(row)
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.streamArea}
|
||||
@@ -750,7 +768,12 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
const text = selectionIn(e.currentTarget);
|
||||
const items = copyMenuFor(e.target) ?? [];
|
||||
if (text.trim() && task.status !== "archived") {
|
||||
items.push({ kind: "action", action: "agents.quoteReply", label: "Quote", arg: { task: task.id, text } });
|
||||
items.push({
|
||||
kind: "action",
|
||||
action: "agents.quoteReply",
|
||||
label: "Quote",
|
||||
arg: { task: task.id, text },
|
||||
});
|
||||
}
|
||||
if (!items.length) return;
|
||||
e.preventDefault();
|
||||
@@ -764,7 +787,10 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
actions={
|
||||
<Button
|
||||
onClick={() =>
|
||||
void executeAction("agents.retryConversation", { task: task.id, subagent: subagent?.id ?? null })
|
||||
void executeAction("agents.retryConversation", {
|
||||
task: task.id,
|
||||
subagent: subagent?.id ?? null,
|
||||
})
|
||||
}
|
||||
>
|
||||
Retry
|
||||
@@ -785,7 +811,7 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
data-message={
|
||||
(row.kind === "entry" && (row.entry.type === "user" || row.entry.type === "agent")) || undefined
|
||||
}
|
||||
data-process={row.kind === "process" || row.entry.type !== "user" || undefined}
|
||||
data-process={row.kind !== "entry" || row.entry.type !== "user" || undefined}
|
||||
>
|
||||
{authorHeaders.has(row.index) && (
|
||||
<div className={styles.agentAuthor}>
|
||||
@@ -795,11 +821,7 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{row.kind === "process" ? (
|
||||
<ProcessRun row={row} task={task} lastIndex={lastIndex} busy={busy} renderRow={renderRow} />
|
||||
) : (
|
||||
renderRow(row)
|
||||
)}
|
||||
{renderConversationRow(row)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -810,17 +832,6 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
|
||||
{subagent.startedMs != null && <Elapsed since={subagent.startedMs} />}
|
||||
</div>
|
||||
)}
|
||||
{!subagent && (task.status === "running" || task.status === "starting") && !waiting && (
|
||||
<div className={styles.working}>
|
||||
<Spinner size={12} />
|
||||
{task.retry ? (
|
||||
<Retrying retry={task.retry} />
|
||||
) : (
|
||||
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
|
||||
)}
|
||||
{lastUser?.sentMs != null && !lastUser.doneMs && <Elapsed since={lastUser.sentMs} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{away && !!entries?.length && <JumpToLatest onJump={jumpToLatest} />}
|
||||
</div>
|
||||
@@ -972,7 +983,11 @@ function FindBar({
|
||||
};
|
||||
update();
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(stream, { subtree: true, childList: true, characterData: true });
|
||||
observer.observe(stream, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
@@ -1076,6 +1091,92 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** Memoized: the earlier entries rendered behind a reopened conversation leave the rest alone. */
|
||||
function AgentMessage({
|
||||
task,
|
||||
text,
|
||||
turnCount,
|
||||
reply,
|
||||
time,
|
||||
working,
|
||||
onDiagramDrawn,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
text: string;
|
||||
turnCount: number;
|
||||
reply?: ReplyTurn;
|
||||
time?: ReplyTimes | null;
|
||||
/** The reply streams: its text is revealed at a pace. */
|
||||
working: boolean;
|
||||
onDiagramDrawn: () => void;
|
||||
}) {
|
||||
const { smoothText } = useAgentSettings();
|
||||
const shown = useRevealedText(text, working && smoothText);
|
||||
// The revealed text grows the conversation between its renders: keep following the end.
|
||||
useLayoutEffect(() => {
|
||||
if (working) onDiagramDrawn();
|
||||
}, [shown, working, onDiagramDrawn]);
|
||||
return (
|
||||
<div className={styles.agentMessage}>
|
||||
<div className={styles.agentCard}>
|
||||
<AgentReply
|
||||
text={shown}
|
||||
cwd={task.cwd}
|
||||
task={task}
|
||||
reply={reply}
|
||||
turnCount={turnCount}
|
||||
onDiagramDrawn={onDiagramDrawn}
|
||||
actions={false}
|
||||
/>
|
||||
</div>
|
||||
{/* Only a turn's final reply carries its time and actions, once the turn has ended. */}
|
||||
{reply && !(isBusy(task.status) && reply.turn === turnCount - 1) && (
|
||||
<div className={styles.messageFooter}>
|
||||
<MessageTime sentMs={time?.sentMs ?? null} doneMs={time?.doneMs ?? null} at="done" />
|
||||
<ReplyActions text={text} task={task} reply={reply} turnCount={turnCount} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The agent's reasoning: open while it streams, folded after, as Settings › Agents › Chat says. */
|
||||
function ThoughtEntry({
|
||||
task,
|
||||
text,
|
||||
working,
|
||||
onDiagramDrawn,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
text: string;
|
||||
working: boolean;
|
||||
onDiagramDrawn: () => void;
|
||||
}) {
|
||||
const { thinking } = useAgentSettings();
|
||||
const open = thinking === "expanded" || (thinking === "auto" && working);
|
||||
return (
|
||||
<div className={styles.process}>
|
||||
<Fold
|
||||
className={clsx(styles.thought, thinking === "auto" && working && styles.thoughtLive)}
|
||||
open={open}
|
||||
summary={
|
||||
<summary>
|
||||
{working ? (
|
||||
<Spinner size={12} />
|
||||
) : (
|
||||
<Icon name="chevron-right" size={12} className={styles.thoughtChevron} />
|
||||
)}
|
||||
<T>{working ? "Thinking" : "Reasoning"}</T>
|
||||
</summary>
|
||||
}
|
||||
>
|
||||
<div className={styles.thoughtBody}>
|
||||
<Markdown text={text} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
</div>
|
||||
</Fold>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Entry = memo(function Entry({
|
||||
task,
|
||||
entry,
|
||||
@@ -1104,46 +1205,18 @@ const Entry = memo(function Entry({
|
||||
return <UserMessage task={task} entry={entry} turn={turn} turnCount={turnCount} />;
|
||||
case "agent":
|
||||
return (
|
||||
<div className={styles.agentMessage}>
|
||||
<div className={styles.agentCard}>
|
||||
<AgentReply
|
||||
text={entry.text}
|
||||
cwd={task.cwd}
|
||||
task={task}
|
||||
reply={reply}
|
||||
turnCount={turnCount}
|
||||
onDiagramDrawn={onDiagramDrawn}
|
||||
actions={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.messageFooter}>
|
||||
<MessageTime sentMs={time?.sentMs ?? null} doneMs={time?.doneMs ?? null} at="done" />
|
||||
<ReplyActions text={entry.text} task={task} reply={reply} turnCount={turnCount} />
|
||||
</div>
|
||||
</div>
|
||||
<AgentMessage
|
||||
task={task}
|
||||
text={entry.text}
|
||||
turnCount={turnCount}
|
||||
reply={reply}
|
||||
time={time}
|
||||
working={working}
|
||||
onDiagramDrawn={onDiagramDrawn}
|
||||
/>
|
||||
);
|
||||
case "thought":
|
||||
return (
|
||||
<div className={styles.process}>
|
||||
<Fold
|
||||
className={styles.thought}
|
||||
summary={
|
||||
<summary>
|
||||
{working ? (
|
||||
<Spinner size={12} />
|
||||
) : (
|
||||
<Icon name="chevron-right" size={12} className={styles.thoughtChevron} />
|
||||
)}
|
||||
<T>{working ? "Thinking" : "Reasoning"}</T>
|
||||
</summary>
|
||||
}
|
||||
>
|
||||
<div className={styles.thoughtBody}>
|
||||
<Markdown text={entry.text} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
</div>
|
||||
</Fold>
|
||||
</div>
|
||||
);
|
||||
return <ThoughtEntry task={task} text={entry.text} working={working} onDiagramDrawn={onDiagramDrawn} />;
|
||||
case "tool":
|
||||
return <ToolEntry task={task} entry={entry} />;
|
||||
case "plan":
|
||||
@@ -1191,7 +1264,8 @@ const Entry = memo(function Entry({
|
||||
</div>
|
||||
);
|
||||
case "permission":
|
||||
return <PermissionLine task={task} entry={entry} />;
|
||||
// Waiting for an answer, it is asked above the composer; the conversation keeps the record.
|
||||
return entry.chosen || entry.questions?.length ? <PermissionLine task={task} entry={entry} /> : null;
|
||||
case "error":
|
||||
return (
|
||||
<div className={styles.error} role="alert">
|
||||
@@ -1245,26 +1319,8 @@ function UserMessage({
|
||||
const files = entry.attachments.slice(0, entry.attachments.length - images.length);
|
||||
return (
|
||||
<div className={clsx(styles.message, styles.userMessage)}>
|
||||
{/* Actions and the time sit beside the author on hover: the reply under it has the turn's duration. */}
|
||||
<div className={styles.messageHead}>
|
||||
<span className={styles.role}>
|
||||
<T>You</T>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.user}>
|
||||
<MessageText text={entry.text} />
|
||||
<ImageStrip images={images} />
|
||||
{files.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
{files.map((a, i) => (
|
||||
<span key={i} className={styles.attachment}>
|
||||
<Icon name={fileIconName(a)} size={12} />
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.messageFooter}>
|
||||
<MessageActions>
|
||||
<IconButton size="small" icon="copy" title="Copy" onClick={() => void copyText(entry.text)} />
|
||||
{inPlace && entry.text && (
|
||||
@@ -1296,12 +1352,29 @@ function UserMessage({
|
||||
<IconButton
|
||||
size="small"
|
||||
icon="branch"
|
||||
title="Copy to a New Task From Here"
|
||||
onClick={() => void forkTask(task.id, turn + 1).catch(failed("Couldn't copy the task"))}
|
||||
title="Fork"
|
||||
onClick={() => void forkTask(task.id, turn + 1).catch(failed("Couldn't fork the task"))}
|
||||
/>
|
||||
)}
|
||||
</MessageActions>
|
||||
<MessageTime sentMs={entry.sentMs ?? null} doneMs={entry.doneMs ?? null} />
|
||||
<MessageTime sentMs={entry.sentMs ?? null} doneMs={null} />
|
||||
<span className={styles.role}>
|
||||
<T>You</T>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.user}>
|
||||
<MessageText text={entry.text} />
|
||||
<ImageStrip images={images} />
|
||||
{files.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
{files.map((a, i) => (
|
||||
<span key={i} className={styles.attachment}>
|
||||
<Icon name={fileIconName(a)} size={12} />
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1337,7 +1410,11 @@ function MessageEditor({
|
||||
await onSend(text.trim(), images);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
notifications().notify({ level: "error", title: "Couldn't send the message", body: String(e) });
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Couldn't send the message",
|
||||
body: String(e),
|
||||
});
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setSending(false);
|
||||
@@ -1437,7 +1514,9 @@ type IndexedEntry = { index: number; entry: AgentEntry };
|
||||
/** A row of the conversation: an entry, or a run of reasoning/tool calls folded together. */
|
||||
type ConversationRow =
|
||||
| { kind: "entry"; index: number; entry: AgentEntry }
|
||||
| { kind: "process"; index: number; entries: IndexedEntry[] };
|
||||
| { kind: "process"; index: number; entries: IndexedEntry[] }
|
||||
/** A finished turn's work before its reply, folded into one line under its message. */
|
||||
| { kind: "turn"; index: number; turn: FoldedTurn; rows: ConversationRow[] };
|
||||
|
||||
type EntryRow = Extract<ConversationRow, { kind: "entry" }>;
|
||||
|
||||
@@ -1451,7 +1530,8 @@ const isProcessEntry = (entry: AgentEntry) => entry.type === "thought" || (entry
|
||||
* The conversation's rows from `from`: a run of reasoning and finished tool calls becomes
|
||||
* one folded row (the work behind a decision), so messages and decisions stay readable.
|
||||
*/
|
||||
export function conversationRows(entries: readonly AgentEntry[], from: number): ConversationRow[] {
|
||||
export function conversationRows(entries: readonly AgentEntry[], from: number, group = true): ConversationRow[] {
|
||||
if (!group) return entries.slice(from).map((entry, i) => ({ kind: "entry", index: from + i, entry }));
|
||||
const rows: ConversationRow[] = [];
|
||||
let run: IndexedEntry[] = [];
|
||||
const flush = () => {
|
||||
@@ -1470,6 +1550,70 @@ export function conversationRows(entries: readonly AgentEntry[], from: number):
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** `rows` with the work of each turn in `turns` (keyed by its message) folded into one row. */
|
||||
export function foldTurnRows(rows: ConversationRow[], turns: ReadonlyMap<number, FoldedTurn>): ConversationRow[] {
|
||||
if (!turns.size) return rows;
|
||||
const out: ConversationRow[] = [];
|
||||
let open: Extract<ConversationRow, { kind: "turn" }> | null = null;
|
||||
for (const row of rows) {
|
||||
if (open && row.index < open.turn.reply) {
|
||||
open.rows.push(row);
|
||||
continue;
|
||||
}
|
||||
open = null;
|
||||
out.push(row);
|
||||
const turn = row.kind === "entry" ? turns.get(row.index) : undefined;
|
||||
if (turn) {
|
||||
open = { kind: "turn", index: row.index + 1, turn, rows: [] };
|
||||
out.push(open);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** "Reasoning · Edited 2 files · Read 4 files": what a run of work did. */
|
||||
function workSummaryText(work: WorkSummary, tx: ReturnType<typeof useTx>, toolCalls: number): string[] {
|
||||
const parts = workParts(work).map((p) => tx(p.text).replace("{count}", String(p.count)));
|
||||
if (!parts.length && toolCalls) parts.push(`${toolCalls} ${tx(toolCalls === 1 ? "tool call" : "tool calls")}`);
|
||||
if (work.thoughts) parts.unshift(tx("Reasoning"));
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** A finished turn's work, folded under its message: one line saying what it did and for how long. */
|
||||
function TurnFold({
|
||||
row,
|
||||
entries,
|
||||
children,
|
||||
}: {
|
||||
row: Extract<ConversationRow, { kind: "turn" }>;
|
||||
entries: readonly AgentEntry[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const { turn } = row;
|
||||
const work = summarizeWork(entries.slice(turn.user + 1, turn.reply));
|
||||
const parts = workSummaryText(work, tx, 0);
|
||||
const messages = entries.slice(turn.user + 1, turn.reply).filter((e) => e.type === "agent").length;
|
||||
if (messages) parts.push(tx(messages === 1 ? "1 message" : "{count} messages").replace("{count}", String(messages)));
|
||||
const took = turn.sentMs != null && turn.doneMs != null ? formatDuration(turn.doneMs - turn.sentMs) : null;
|
||||
return (
|
||||
<Fold
|
||||
className={clsx(styles.turnFold, styles.process)}
|
||||
summary={
|
||||
<summary className={styles.processSummary}>
|
||||
<Icon name="chevron-right" size={12} className={styles.processChevron} />
|
||||
<span>{parts.join(" · ")}</span>
|
||||
{work.added > 0 && <span className={styles.added}>+{work.added}</span>}
|
||||
{work.removed > 0 && <span className={styles.removed}>−{work.removed}</span>}
|
||||
{took && <span className={styles.muted}>{took}</span>}
|
||||
</summary>
|
||||
}
|
||||
>
|
||||
<div className={styles.turnBody}>{children}</div>
|
||||
</Fold>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reasoning and tool calls between messages, folded into one row. */
|
||||
const ProcessRun = memo(function ProcessRun({
|
||||
row,
|
||||
@@ -1489,14 +1633,10 @@ const ProcessRun = memo(function ProcessRun({
|
||||
const tools = row.entries.filter(
|
||||
(item): item is IndexedEntry & { entry: ToolEntryData } => item.entry.type === "tool",
|
||||
);
|
||||
const reasoning = row.entries.filter(({ entry }) => entry.type === "thought").length;
|
||||
const added = tools.reduce((n, { entry }) => n + entry.diffs.reduce((sum, d) => sum + d.added, 0), 0);
|
||||
const removed = tools.reduce((n, { entry }) => n + entry.diffs.reduce((sum, d) => sum + d.removed, 0), 0);
|
||||
const work = summarizeWork(row.entries.map(({ entry }) => entry));
|
||||
const { added, removed } = work;
|
||||
const working = busy && row.entries.some(({ index }) => index === lastIndex);
|
||||
const parts = [
|
||||
reasoning > 0 ? tx("Reasoning") : null,
|
||||
tools.length > 0 ? `${tools.length} ${tx(tools.length === 1 ? "tool call" : "tool calls")}` : null,
|
||||
].filter(Boolean);
|
||||
const parts = workSummaryText(work, tx, tools.length);
|
||||
return (
|
||||
<Fold
|
||||
className={clsx(styles.processRun, styles.process)}
|
||||
@@ -1558,6 +1698,7 @@ const ToolEntry = memo(function ToolEntry({
|
||||
task: AgentTask;
|
||||
entry: Extract<AgentEntry, { type: "tool" }>;
|
||||
}) {
|
||||
const { expandToolOutput } = useAgentSettings();
|
||||
const head = (
|
||||
<>
|
||||
<Icon name={TOOL_ICONS[entry.kind] ?? "run"} size={14} />
|
||||
@@ -1592,7 +1733,7 @@ const ToolEntry = memo(function ToolEntry({
|
||||
<div className={styles.process}>
|
||||
<Fold
|
||||
className={styles.toolDetails}
|
||||
open={liveTool(entry)}
|
||||
open={liveTool(entry) || expandToolOutput}
|
||||
summary={
|
||||
<summary className={styles.tool}>
|
||||
<Icon name="chevron-right" size={12} className={styles.toolChevron} />
|
||||
@@ -2163,17 +2304,168 @@ function QuestionForm({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What needs the user or tells where the agent is, above the composer: an approval it
|
||||
* waits for (answered with 1, 2, 3… when the focus isn't in a text field), the plan, the
|
||||
* task's changes, then what runs and what is queued, and a line saying what it does now.
|
||||
*/
|
||||
function Tray({
|
||||
task,
|
||||
entries,
|
||||
changes,
|
||||
changesOpen,
|
||||
onToggleChanges,
|
||||
children,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
changes: AgentChange[] | null;
|
||||
changesOpen: boolean;
|
||||
onToggleChanges: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const pending = entries?.find(
|
||||
(e): e is PermissionEntry => e.type === "permission" && !e.chosen && !e.questions?.length,
|
||||
);
|
||||
const plan = entries?.findLast((e): e is Extract<AgentEntry, { type: "plan" }> => e.type === "plan");
|
||||
const [planOpen, setPlanOpen] = useState(false);
|
||||
const done = plan?.items.filter((i) => i.status === "completed").length ?? 0;
|
||||
const planShown = plan && plan.items.length > 0 && done < plan.items.length;
|
||||
const added = changes?.reduce((n, c) => n + c.added, 0) ?? 0;
|
||||
const removed = changes?.reduce((n, c) => n + c.removed, 0) ?? 0;
|
||||
const busy = task.status === "running" || task.status === "starting";
|
||||
const tool = entries && busy ? currentTool(entries) : null;
|
||||
const lastUser = entries?.findLast((e): e is UserEntry => e.type === "user");
|
||||
return (
|
||||
<div className={styles.tray}>
|
||||
{pending && <PendingPermission task={task} entry={pending} />}
|
||||
{(planShown || !!changes?.length) && (
|
||||
<div className={styles.trayStrip}>
|
||||
{planShown && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trayChip}
|
||||
aria-expanded={planOpen}
|
||||
onClick={() => setPlanOpen(!planOpen)}
|
||||
>
|
||||
<Icon name="todo" size={13} />
|
||||
{tx`Plan ${done}/${plan.items.length}`}
|
||||
</button>
|
||||
)}
|
||||
{!!changes?.length && (
|
||||
<button type="button" className={styles.trayChip} aria-pressed={changesOpen} onClick={onToggleChanges}>
|
||||
<Icon name="edit" size={13} />
|
||||
{tx(changes.length === 1 ? "1 changed file" : "{count} changed files").replace(
|
||||
"{count}",
|
||||
String(changes.length),
|
||||
)}
|
||||
{added > 0 && <span className={styles.added}>+{added}</span>}
|
||||
{removed > 0 && <span className={styles.removed}>−{removed}</span>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{planShown && planOpen && (
|
||||
<div className={styles.trayPlan}>
|
||||
{plan.items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
styles.planItem,
|
||||
item.status === "completed" && styles.planDone,
|
||||
item.status === "in_progress" && styles.planCurrent,
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={item.status === "completed" ? "check" : item.status === "in_progress" ? "run" : "minus"}
|
||||
size={12}
|
||||
/>
|
||||
{item.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{busy && !pending && (
|
||||
<div className={styles.working} role="status">
|
||||
<Spinner size={12} />
|
||||
{task.retry ? (
|
||||
<Retrying retry={task.retry} />
|
||||
) : tool ? (
|
||||
<span className={styles.trayActivity} title={tool.title}>
|
||||
{shortTitle(task, tool.title || tool.kind)}
|
||||
</span>
|
||||
) : (
|
||||
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
|
||||
)}
|
||||
{lastUser?.sentMs != null && !lastUser.doneMs && <Elapsed since={lastUser.sentMs} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The approval the agent waits for, with its answers on 1, 2, 3… */
|
||||
function PendingPermission({ task, entry }: { task: AgentTask; entry: PermissionEntry }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const shown = useEditorShown();
|
||||
useEffect(() => {
|
||||
if (!shown) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const el = ref.current;
|
||||
if (!el || e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
// Only while this session holds the focus: keys typed elsewhere never answer for it.
|
||||
if (!target || !el.closest(`.${styles.session}`)?.contains(target)) return;
|
||||
if (target.closest("input, textarea, [contenteditable='true'], .cm-editor")) return;
|
||||
const n = Number(e.key);
|
||||
if (!Number.isInteger(n) || n < 1) return;
|
||||
const button = el.querySelectorAll<HTMLButtonElement>(`.${styles.permissionOption}`)[n - 1];
|
||||
if (!button || button.disabled) return;
|
||||
e.preventDefault();
|
||||
button.click();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [shown]);
|
||||
// Answered, the card goes: the focus it had moves on to the composer.
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
const area = el?.closest(`.${styles.composerArea}`);
|
||||
return () => {
|
||||
if (!el?.contains(document.activeElement) && document.activeElement !== document.body) return;
|
||||
requestAnimationFrame(() => {
|
||||
if (document.activeElement === document.body || !document.activeElement?.isConnected)
|
||||
area?.querySelector<HTMLElement>("textarea")?.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<div ref={ref} className={styles.trayAsk}>
|
||||
<PermissionLine task={task} entry={entry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionComposer({
|
||||
task,
|
||||
entries,
|
||||
head,
|
||||
changes,
|
||||
changesOpen,
|
||||
onToggleChanges,
|
||||
onOpenSubagent,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
head: string | null;
|
||||
changes: AgentChange[] | null;
|
||||
changesOpen: boolean;
|
||||
onToggleChanges: () => void;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
}) {
|
||||
const { sendWith, whileRunning } = useAgentSettings();
|
||||
const project = useProject((s) => s.project);
|
||||
const composer = useAgents((s) => s.composers.get(task.id)) ?? emptyDraft();
|
||||
const [sending, setSending] = useState(false);
|
||||
@@ -2184,22 +2476,44 @@ function SessionComposer({
|
||||
() => (entries ?? []).flatMap((e) => (e.type === "user" && e.text ? [e.text] : [])).reverse(),
|
||||
[entries],
|
||||
);
|
||||
// Only a running turn of an agent that takes messages mid-turn can be steered: otherwise
|
||||
// "now" would cancel the turn, so the message waits for it instead.
|
||||
const steers =
|
||||
whileRunning === "steer" &&
|
||||
!!task.capabilities?.steer &&
|
||||
task.status === "running" &&
|
||||
(composer.images.length === 0 || !!task.capabilities.images);
|
||||
const send = (now: boolean) => {
|
||||
if (!canSend(composer) || sending || task.status === "archived") return;
|
||||
setSending(true);
|
||||
sendMessage(task.id, now)
|
||||
sendMessage(task.id, now && (whileRunning !== "steer" || steers))
|
||||
.catch((e: unknown) =>
|
||||
notifications().notify({ level: "error", title: "Couldn't send the message", body: String(e) }),
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Couldn't send the message",
|
||||
body: String(e),
|
||||
}),
|
||||
)
|
||||
.finally(() => setSending(false));
|
||||
};
|
||||
const setOption = (id: string, value: string) =>
|
||||
void setTaskConfig(task.id, id, value).catch((e: unknown) =>
|
||||
notifications().notify({ level: "error", title: "Couldn't change the setting", body: String(e) }),
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Couldn't change the setting",
|
||||
body: String(e),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<div className={styles.composerArea}>
|
||||
<SessionStatus task={task} entries={entries} head={head} />
|
||||
<SessionStatus
|
||||
task={task}
|
||||
entries={entries}
|
||||
head={head}
|
||||
changes={changes}
|
||||
changesOpen={changesOpen}
|
||||
onToggleChanges={onToggleChanges}
|
||||
/>
|
||||
{task.status === "archived" && (
|
||||
<div className={styles.banner}>
|
||||
<span className={styles.bannerText}>
|
||||
@@ -2212,12 +2526,24 @@ function SessionComposer({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<WorkingSubagents entries={entries} onOpen={onOpenSubagent} />
|
||||
<BackgroundTasks task={task} />
|
||||
{task.queue.length > 0 && <Queue task={task} />}
|
||||
<Tray task={task} entries={entries} changes={changes} changesOpen={changesOpen} onToggleChanges={onToggleChanges}>
|
||||
<WorkingSubagents entries={entries} onOpen={onOpenSubagent} />
|
||||
<BackgroundTasks task={task} />
|
||||
{task.queue.length > 0 && <Queue task={task} />}
|
||||
</Tray>
|
||||
<Composer
|
||||
id={task.id}
|
||||
placeholder={busy ? "Queue a message for when the agent is done (⌘↵ sends it now)" : "Reply to the agent"}
|
||||
placeholder={
|
||||
!busy
|
||||
? "Reply to the agent"
|
||||
: steers
|
||||
? "Send into the agent's turn"
|
||||
: whileRunning === "steer"
|
||||
? "Queue a message for when the agent is done"
|
||||
: sendWith === "enter"
|
||||
? "Queue a message for when the agent is done (⌘↵ sends it now)"
|
||||
: "Queue a message for when the agent is done (⌘⇧↵ sends it now)"
|
||||
}
|
||||
root={task.cwd}
|
||||
projectRoot={project?.root ?? task.cwd}
|
||||
commands={agentCommands}
|
||||
@@ -2245,7 +2571,10 @@ function SessionComposer({
|
||||
}
|
||||
|
||||
/** Messages waiting for the turn to end: send now, edit, or drop. */
|
||||
const BACKGROUND_ICON: Record<string, IconName> = { local_bash: "terminal", monitor: "eye" };
|
||||
const BACKGROUND_ICON: Record<string, IconName> = {
|
||||
local_bash: "terminal",
|
||||
monitor: "eye",
|
||||
};
|
||||
|
||||
/** What the agent runs in the background; its subagents show as chips of their own. */
|
||||
function BackgroundTasks({ task }: { task: AgentTask }) {
|
||||
@@ -2253,7 +2582,11 @@ function BackgroundTasks({ task }: { task: AgentTask }) {
|
||||
const running = (task.background ?? []).filter((background) => background.kind !== "local_agent");
|
||||
if (!running.length) return null;
|
||||
const fail = (e: unknown) =>
|
||||
notifications().notify({ level: "error", title: "Couldn't stop the background task", body: String(e) });
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Couldn't stop the background task",
|
||||
body: String(e),
|
||||
});
|
||||
return (
|
||||
<div className={styles.background} aria-label={tx("Running in the background")}>
|
||||
{running.map((background) => (
|
||||
@@ -2279,7 +2612,11 @@ function BackgroundTasks({ task }: { task: AgentTask }) {
|
||||
|
||||
function Queue({ task }: { task: AgentTask }) {
|
||||
const fail = (e: unknown) =>
|
||||
notifications().notify({ level: "error", title: "Couldn't update the queue", body: String(e) });
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Couldn't update the queue",
|
||||
body: String(e),
|
||||
});
|
||||
return (
|
||||
<div className={styles.queue} aria-label={tx("Queued messages")}>
|
||||
{task.queue.map((q) => (
|
||||
@@ -2328,7 +2665,10 @@ function useChangesOpen(
|
||||
changes: AgentChange[] | null,
|
||||
docked: boolean,
|
||||
): [boolean, (open: boolean) => void] {
|
||||
const [state, setState] = useState<{ task: string; open: boolean | undefined }>(() => ({
|
||||
const [state, setState] = useState<{
|
||||
task: string;
|
||||
open: boolean | undefined;
|
||||
}>(() => ({
|
||||
task,
|
||||
open: docked ? false : loadChangesOpen()[task],
|
||||
}));
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
/** The tab strip's agent status indicator: spinner while the task is busy, dot once done (#2233). */
|
||||
/**
|
||||
* The tab strip's agent status indicator (#2233): a spinner while the task is busy, then a
|
||||
* dot only while something waits for you (a finished reply not yet read, a question, an error).
|
||||
*/
|
||||
import { Badge, Spinner, tooltipAttrs } from "@/ui";
|
||||
import { isBusy, statusLabel } from "./model";
|
||||
import { useAgents } from "./store";
|
||||
|
||||
export function TaskStatusMark({ task }: { task: string }) {
|
||||
const status = useAgents((s) => s.tasks.get(task)?.status);
|
||||
const unread = useAgents((s) => s.unread.has(task));
|
||||
if (!status) return null;
|
||||
const busy = isBusy(status);
|
||||
// Waiting for you is a dot, not a spinner: the task stops until you answer.
|
||||
const busy = isBusy(status) && status !== "needsInput";
|
||||
const tone =
|
||||
status === "failed" || status === "limited"
|
||||
? "danger"
|
||||
: status === "needsInput"
|
||||
? "warning"
|
||||
: status === "ready" && unread
|
||||
? "success"
|
||||
: null;
|
||||
if (!busy && !tone) return null;
|
||||
const label = statusLabel(status);
|
||||
return (
|
||||
<span {...tooltipAttrs(label, undefined, true)} style={{ display: "inline-flex", alignItems: "center" }}>
|
||||
{busy ? (
|
||||
<Spinner size={10} variant="spokes" label={label} />
|
||||
) : (
|
||||
<Badge
|
||||
dot
|
||||
tone={status === "ready" ? "success" : status === "failed" || status === "limited" ? "danger" : "neutral"}
|
||||
>
|
||||
<Badge dot tone={tone!}>
|
||||
{label}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ function AgentRegistryPage(props: ComponentProps<typeof RegistryPage>) {
|
||||
}
|
||||
import { ADD_AGENT, providerOptions } from "./providerOptions";
|
||||
import {
|
||||
afterInstall,
|
||||
afterRemove,
|
||||
matchesQuery,
|
||||
rowActions,
|
||||
@@ -80,19 +81,30 @@ class FakeBackend implements RegistryBackend {
|
||||
this.installRequests.push(request);
|
||||
this.emit = onEvent;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.finish = resolve;
|
||||
this.finish = () => {
|
||||
this.registry = { ...this.registry, agents: afterInstall(this.registry.agents, id) };
|
||||
resolve();
|
||||
};
|
||||
this.fail = reject;
|
||||
});
|
||||
}
|
||||
async remove(id: string) {
|
||||
this.removed.push(id);
|
||||
this.registry = { ...this.registry, agents: afterRemove(this.registry.agents, id) };
|
||||
}
|
||||
cancel(request: RequestId) {
|
||||
this.cancelled.push(request);
|
||||
}
|
||||
}
|
||||
|
||||
const row = (name: string) => screen.getByRole("listitem", { name });
|
||||
/** The registry agent's details, selecting it in the list first. */
|
||||
const row = (name: string) => {
|
||||
const shown = screen.queryByRole("region", { name });
|
||||
if (shown) return shown;
|
||||
fireEvent.mouseDown(screen.getByRole("option", { name: new RegExp(`^${name}`) }));
|
||||
return screen.getByRole("region", { name });
|
||||
};
|
||||
const item = (name: string) => screen.getByRole("option", { name: new RegExp(`^${name}`) });
|
||||
|
||||
describe("registry helpers", () => {
|
||||
it("matches every word of the query against name, id, description and authors", () => {
|
||||
@@ -176,17 +188,35 @@ describe("agent picker", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agents settings page", () => {
|
||||
const renderRegistry = (...args: Parameters<typeof render>) => {
|
||||
const result = render(...args);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "ACP Registry" }));
|
||||
return result;
|
||||
describe("the ACP Registry in the agent list", () => {
|
||||
/** The page, with the registry's installed agents as the machine's agents. */
|
||||
const renderRegistry = (backend: FakeBackend, onChanged?: () => void) => {
|
||||
const commands = new FakeCommands();
|
||||
commands.providers.mockImplementation(async () =>
|
||||
backend.registry.agents
|
||||
.filter((a) => a.installed != null)
|
||||
.map((a) => ({
|
||||
...claudeProvider("x"),
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
loginHint: null,
|
||||
commandEditable: false,
|
||||
commandReadOnlyReason: "registry" as const,
|
||||
source: { kind: "registry" as const, version: a.installed!, update: a.update ? a.version : null },
|
||||
})),
|
||||
);
|
||||
return render(
|
||||
<>
|
||||
<AgentRegistryPage backend={backend} commandBackend={commands} onChanged={onChanged} />
|
||||
<MessagesHost />
|
||||
</>,
|
||||
);
|
||||
};
|
||||
it("shows the built-in Copilot name and icon in its registry row", async () => {
|
||||
const backend = new FakeBackend();
|
||||
backend.registry = { agents: [agent({ id: "copilot", name: "GitHub Copilot" })], warning: null };
|
||||
renderRegistry(<AgentRegistryPage backend={backend} />);
|
||||
const copilot = await screen.findByRole("listitem", { name: "GitHub Copilot" });
|
||||
renderRegistry(backend);
|
||||
const copilot = await screen.findByRole("option", { name: "GitHub Copilot" });
|
||||
const expected = document.createElement("div");
|
||||
expected.innerHTML = iconMarkup("agent-copilot")!;
|
||||
expect(copilot.querySelector("svg")?.innerHTML).toBe(expected.querySelector("svg")?.innerHTML);
|
||||
@@ -195,18 +225,20 @@ describe("Agents settings page", () => {
|
||||
|
||||
it("lists registry agents, says they are third-party, and searches them", async () => {
|
||||
const backend = new FakeBackend();
|
||||
renderRegistry(<AgentRegistryPage backend={backend} />);
|
||||
renderRegistry(backend);
|
||||
await waitFor(() => expect(row("Amp")).toBeInTheDocument());
|
||||
expect(screen.getByText(/third-party software/)).toBeInTheDocument();
|
||||
expect(within(row("Amp")).getByRole("button", { name: "Install" })).toBeEnabled();
|
||||
expect(within(row("Amp")).getByRole("button", { name: "Repository" })).toBeInTheDocument();
|
||||
expect(within(row("Cline")).getByText("3.0.74 available")).toBeInTheDocument();
|
||||
expect(within(item("Cline")).getByText("Update")).toBeInTheDocument();
|
||||
expect(within(row("Cline")).getByText("ACP Registry · 3.0.70")).toBeInTheDocument();
|
||||
expect(within(row("Cline")).getByText("3.0.74 is available")).toBeInTheDocument();
|
||||
expect(within(row("Cline")).getByRole("button", { name: "Update" })).toBeInTheDocument();
|
||||
expect(within(row("fast-agent")).getByRole("button", { name: "Remove" })).toBeInTheDocument();
|
||||
expect(within(row("fast-agent")).queryByRole("button", { name: "Update" })).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search agents" }), { target: { value: "frontier" } });
|
||||
expect(screen.getAllByRole("listitem")).toHaveLength(1);
|
||||
expect(screen.getAllByRole("option")).toHaveLength(1);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search agents" }), { target: { value: "nothing" } });
|
||||
expect(screen.getByText("No agents match")).toBeInTheDocument();
|
||||
});
|
||||
@@ -214,7 +246,7 @@ describe("Agents settings page", () => {
|
||||
it("installs with progress, then reports the change", async () => {
|
||||
const backend = new FakeBackend();
|
||||
const onChanged = vi.fn();
|
||||
renderRegistry(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
|
||||
renderRegistry(backend, onChanged);
|
||||
await waitFor(() => expect(row("Amp")).toBeInTheDocument());
|
||||
fireEvent.click(within(row("Amp")).getByRole("button", { name: "Install" }));
|
||||
expect(backend.installs).toEqual(["amp-acp"]);
|
||||
@@ -226,21 +258,16 @@ describe("Agents settings page", () => {
|
||||
|
||||
await act(async () => backend.finish());
|
||||
expect(onChanged).toHaveBeenCalledTimes(1);
|
||||
expect(within(row("Amp")).getByText("Installed")).toBeInTheDocument();
|
||||
expect(within(row("Amp")).getByRole("button", { name: "Remove" })).toBeEnabled();
|
||||
await waitFor(() => expect(within(row("Amp")).getByRole("button", { name: "Remove" })).toBeEnabled());
|
||||
expect(within(row("Amp")).getByText("ACP Registry · 0.10.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks before installing an agent whose download has no checksum", async () => {
|
||||
const backend = new FakeBackend();
|
||||
backend.registry = { agents: [{ ...AMP, noChecksum: true }], warning: null };
|
||||
renderRegistry(
|
||||
<>
|
||||
<AgentRegistryPage backend={backend} />
|
||||
<MessagesHost />
|
||||
</>,
|
||||
);
|
||||
renderRegistry(backend);
|
||||
await waitFor(() => expect(row("Amp")).toBeInTheDocument());
|
||||
expect(within(row("Amp")).getByText("No checksum provided")).toBeInTheDocument();
|
||||
expect(within(row("Amp")).getByText(/No checksum provided/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(row("Amp")).getByRole("button", { name: "Install" }));
|
||||
let dialog = await screen.findByRole("dialog");
|
||||
@@ -257,12 +284,7 @@ describe("Agents settings page", () => {
|
||||
|
||||
it("installs an agent with a checksum without asking", async () => {
|
||||
const backend = new FakeBackend();
|
||||
renderRegistry(
|
||||
<>
|
||||
<AgentRegistryPage backend={backend} />
|
||||
<MessagesHost />
|
||||
</>,
|
||||
);
|
||||
renderRegistry(backend);
|
||||
await waitFor(() => expect(row("Amp")).toBeInTheDocument());
|
||||
expect(screen.queryByText("No checksum provided")).toBeNull();
|
||||
fireEvent.click(within(row("Amp")).getByRole("button", { name: "Install" }));
|
||||
@@ -272,7 +294,7 @@ describe("Agents settings page", () => {
|
||||
|
||||
it("shows why an install failed, cancels one on Cancel, and leaves it running when the page closes", async () => {
|
||||
const backend = new FakeBackend();
|
||||
const { unmount } = renderRegistry(<AgentRegistryPage backend={backend} />);
|
||||
const { unmount } = renderRegistry(backend);
|
||||
await waitFor(() => expect(row("Cline")).toBeInTheDocument());
|
||||
fireEvent.click(within(row("Cline")).getByRole("button", { name: "Update" }));
|
||||
await act(async () => backend.fail(new IpcError({ kind: "unavailable", message: "npm is required" })));
|
||||
@@ -296,7 +318,7 @@ describe("Agents settings page", () => {
|
||||
const backend = new FakeBackend();
|
||||
const onChanged = vi.fn();
|
||||
backend.registry = { agents: [{ ...AMP, installing: true }, CLINE], warning: null };
|
||||
renderRegistry(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
|
||||
renderRegistry(backend, onChanged);
|
||||
await waitFor(() => expect(row("Amp")).toBeInTheDocument());
|
||||
expect(within(row("Amp")).getByText("Installing Amp in the background…")).toBeInTheDocument();
|
||||
// It is stopped from the background tasks, and nothing else starts meanwhile.
|
||||
@@ -306,7 +328,9 @@ describe("Agents settings page", () => {
|
||||
expect(within(row("Cline")).getByRole("button", { name: "Update" })).toBeDisabled();
|
||||
|
||||
backend.registry = { agents: [{ ...AMP, installed: AMP.version }, CLINE], warning: null };
|
||||
await waitFor(() => expect(within(row("Amp")).getByText("Installed")).toBeInTheDocument(), { timeout: 4000 });
|
||||
await waitFor(() => expect(within(row("Amp")).getByRole("button", { name: "Remove" })).toBeInTheDocument(), {
|
||||
timeout: 4000,
|
||||
});
|
||||
expect(onChanged).toHaveBeenCalledTimes(1);
|
||||
expect(within(row("Cline")).getByRole("button", { name: "Update" })).toBeEnabled();
|
||||
});
|
||||
@@ -314,9 +338,11 @@ describe("Agents settings page", () => {
|
||||
it("removes an installed agent", async () => {
|
||||
const backend = new FakeBackend();
|
||||
const onChanged = vi.fn();
|
||||
renderRegistry(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
|
||||
renderRegistry(backend, onChanged);
|
||||
await waitFor(() => expect(row("fast-agent")).toBeInTheDocument());
|
||||
fireEvent.click(within(row("fast-agent")).getByRole("button", { name: "Remove" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Remove fast-agent" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
|
||||
await waitFor(() => expect(within(row("fast-agent")).getByRole("button", { name: "Install" })).toBeEnabled());
|
||||
expect(backend.removed).toEqual(["fast-agent"]);
|
||||
expect(onChanged).toHaveBeenCalledTimes(1);
|
||||
@@ -325,12 +351,12 @@ describe("Agents settings page", () => {
|
||||
it("falls back to the saved list with a warning, and reports a registry it cannot load", async () => {
|
||||
const backend = new FakeBackend();
|
||||
backend.registry = { agents: AGENTS, warning: "Could not reach the ACP Registry" };
|
||||
const { unmount } = renderRegistry(<AgentRegistryPage backend={backend} />);
|
||||
const { unmount } = renderRegistry(backend);
|
||||
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Could not reach the ACP Registry"));
|
||||
unmount();
|
||||
|
||||
backend.list.mockRejectedValueOnce(new IpcError({ kind: "io", message: "offline" }));
|
||||
renderRegistry(<AgentRegistryPage backend={backend} />);
|
||||
renderRegistry(backend);
|
||||
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Cannot load the ACP Registry: offline"));
|
||||
});
|
||||
});
|
||||
@@ -397,12 +423,12 @@ describe("Built-in executable overrides", () => {
|
||||
component: () => <RegistryPage backend={backend} commandBackend={commands} onChanged={onChanged} />,
|
||||
});
|
||||
render(<SettingsDialog />);
|
||||
return screen.findByRole("region", { name: "Claude Code executable" });
|
||||
return screen.findByRole("region", { name: "Claude Code" });
|
||||
};
|
||||
const apply = () => screen.getByRole("button", { name: "Apply" });
|
||||
const field = () => screen.getByRole("textbox", { name: "Executable" });
|
||||
|
||||
it("follows locale changes in tabs and renders login commands as inline code", async () => {
|
||||
it("follows locale changes and renders login commands as inline code", async () => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [
|
||||
claudeProvider("claude"),
|
||||
@@ -424,14 +450,10 @@ describe("Built-in executable overrides", () => {
|
||||
await setLocale("zh");
|
||||
});
|
||||
expect(screen.getByRole("option", { name: "Codex 未找到" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "内置" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "ACP 注册表" })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await setLocale("en");
|
||||
});
|
||||
expect(screen.getByRole("option", { name: "Codex Not found" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Built-in" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "ACP Registry" })).toBeInTheDocument();
|
||||
} finally {
|
||||
await act(async () => {
|
||||
await setLocale("en");
|
||||
@@ -653,3 +675,111 @@ describe("Built-in executable overrides", () => {
|
||||
expect(commands.saved).toEqual([["claude", "/chosen/agent"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the agent list", () => {
|
||||
let dispose = () => {};
|
||||
beforeEach(() => openSettings(REGISTRY_PAGE));
|
||||
afterEach(() => {
|
||||
closeSettings();
|
||||
dispose();
|
||||
});
|
||||
const open = async (commands: FakeCommands, backend = new FakeBackend()) => {
|
||||
dispose = registerSettingsPage({
|
||||
id: REGISTRY_PAGE,
|
||||
title: "Agents",
|
||||
component: () => <RegistryPage backend={backend} commandBackend={commands} />,
|
||||
});
|
||||
render(
|
||||
<>
|
||||
<SettingsDialog />
|
||||
<MessagesHost />
|
||||
</>,
|
||||
);
|
||||
await screen.findByRole("region", { name: "Claude Code" });
|
||||
return backend;
|
||||
};
|
||||
const model = {
|
||||
id: "model",
|
||||
name: "Model",
|
||||
description: null,
|
||||
category: "model",
|
||||
current: "sonnet",
|
||||
values: [
|
||||
{ value: "sonnet", name: "Sonnet", description: null },
|
||||
{ value: "opus", name: "Opus", description: null },
|
||||
],
|
||||
via: "option",
|
||||
} as AgentProvider["config"][number];
|
||||
|
||||
it("lists registry agents beside the built-ins, updates one and removes it", async () => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [
|
||||
claudeProvider("claude"),
|
||||
{
|
||||
...claudeProvider("cline"),
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
commandEditable: false,
|
||||
commandReadOnlyReason: "registry",
|
||||
source: { kind: "registry", version: "3.0.70", update: "3.0.74" },
|
||||
},
|
||||
];
|
||||
const backend = await open(commands);
|
||||
fireEvent.mouseDown(screen.getByRole("option", { name: "Cline Update" }));
|
||||
const details = await screen.findByRole("region", { name: "Cline" });
|
||||
expect(within(details).getByText("ACP Registry · 3.0.70")).toBeInTheDocument();
|
||||
fireEvent.click(within(details).getByRole("button", { name: "Update" }));
|
||||
expect(backend.installs).toEqual(["cline"]);
|
||||
await act(async () => backend.finish());
|
||||
fireEvent.click(within(screen.getByRole("region", { name: "Cline" })).getByRole("button", { name: "Remove" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Remove Cline" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
|
||||
await waitFor(() => expect(backend.removed).toEqual(["cline"]));
|
||||
});
|
||||
|
||||
it("lists every agent, installed first, and filters them", async () => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [
|
||||
claudeProvider("claude"),
|
||||
{
|
||||
...claudeProvider("codex"),
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
defaultProgram: "codex",
|
||||
commandOverridden: false,
|
||||
installed: false,
|
||||
},
|
||||
];
|
||||
await open(commands);
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: /^Amp/ })).toBeInTheDocument());
|
||||
const names = screen.getAllByRole("option").map((o) => o.textContent?.trim());
|
||||
expect(names).toEqual(["Claude Code", "Cline Update", "fast-agent", "Codex Not found", "Amp"]);
|
||||
expect(screen.getByText("Installed")).toBeInTheDocument();
|
||||
expect(screen.getByText("Available")).toBeInTheDocument();
|
||||
fireEvent.mouseDown(screen.getByRole("option", { name: /^Amp/ }));
|
||||
const amp = screen.getByRole("region", { name: "Amp" });
|
||||
expect(within(amp).getByRole("button", { name: "Install" })).toBeEnabled();
|
||||
expect(within(amp).queryByRole("region", { name: "Launch" })).toBeNull();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search agents" }), { target: { value: "codex" } });
|
||||
expect(screen.getAllByRole("option").map((o) => o.textContent?.trim())).toEqual(["Codex Not found"]);
|
||||
expect(screen.queryByText("Installed")).toBeNull();
|
||||
});
|
||||
|
||||
it("stages the settings new tasks start with", async () => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [{ ...claudeProvider("claude"), config: [model] }];
|
||||
await open(commands);
|
||||
const select = screen.getByRole("button", { name: "Model" });
|
||||
expect(select).toHaveTextContent("Agent's default (Sonnet)");
|
||||
fireEvent.click(select);
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Opus" }));
|
||||
expect(screen.getByRole("button", { name: "Model" })).toHaveTextContent("Opus");
|
||||
});
|
||||
|
||||
it("says when an agent's settings aren't known yet", async () => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [claudeProvider("claude")];
|
||||
await open(commands);
|
||||
expect(screen.getByText(/after its first session/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Settings › Agents, with the defaults filled in. */
|
||||
import { useMemo } from "react";
|
||||
import type { AgentSettings } from "@/bindings";
|
||||
import { useProject } from "@/platform/project";
|
||||
|
||||
export type ResolvedAgentSettings = Required<AgentSettings>;
|
||||
|
||||
export const AGENT_SETTINGS_DEFAULTS: ResolvedAgentSettings = {
|
||||
showContextUsage: true,
|
||||
showWorkTime: true,
|
||||
showUsageLimits: true,
|
||||
showCost: false,
|
||||
foldTurns: true,
|
||||
smoothText: true,
|
||||
thinking: "auto",
|
||||
toolCalls: "grouped",
|
||||
expandToolOutput: false,
|
||||
sendWith: "enter",
|
||||
whileRunning: "queue",
|
||||
defaults: {},
|
||||
};
|
||||
|
||||
export function resolveAgentSettings(s: AgentSettings | null | undefined): ResolvedAgentSettings {
|
||||
const set = Object.fromEntries(Object.entries(s ?? {}).filter(([, v]) => v !== undefined && v !== null));
|
||||
return { ...AGENT_SETTINGS_DEFAULTS, ...set };
|
||||
}
|
||||
|
||||
export function useAgentSettings(): ResolvedAgentSettings {
|
||||
const s = useProject((p) => p.settings?.agents);
|
||||
return useMemo(() => resolveAgentSettings(s), [s]);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "./composerModel";
|
||||
import {
|
||||
age,
|
||||
appendEntry,
|
||||
attentionChange,
|
||||
branchLabel,
|
||||
entriesKey,
|
||||
@@ -92,12 +93,30 @@ describe("mergeMatches", () => {
|
||||
|
||||
describe("agent composer", () => {
|
||||
it("completes @files and /commands anywhere at a word boundary", () => {
|
||||
expect(completionAt("fix @src/le", 11)).toEqual({ kind: "file", query: "src/le", from: 4, to: 11 });
|
||||
expect(completionAt("/rev", 4)).toEqual({ kind: "command", query: "rev", from: 0, to: 4 });
|
||||
expect(completionAt("run /rev", 8)).toEqual({ kind: "command", query: "rev", from: 4, to: 8 });
|
||||
expect(completionAt("fix @src/le", 11)).toEqual({
|
||||
kind: "file",
|
||||
query: "src/le",
|
||||
from: 4,
|
||||
to: 11,
|
||||
});
|
||||
expect(completionAt("/rev", 4)).toEqual({
|
||||
kind: "command",
|
||||
query: "rev",
|
||||
from: 0,
|
||||
to: 4,
|
||||
});
|
||||
expect(completionAt("run /rev", 8)).toEqual({
|
||||
kind: "command",
|
||||
query: "rev",
|
||||
from: 4,
|
||||
to: 8,
|
||||
});
|
||||
expect(completionAt("a@b", 3)).toBeNull();
|
||||
const c = completionAt("see @le and", 7)!;
|
||||
expect(applyCompletion("see @le and", c, "@src/lexer.rs")).toEqual({ text: "see @src/lexer.rs and", caret: 18 });
|
||||
expect(applyCompletion("see @le and", c, "@src/lexer.rs")).toEqual({
|
||||
text: "see @src/lexer.rs and",
|
||||
caret: 18,
|
||||
});
|
||||
const cmds = [
|
||||
{ name: "init", description: "", hint: null },
|
||||
{ name: "review", description: "", hint: null },
|
||||
@@ -153,7 +172,11 @@ describe("agent composer", () => {
|
||||
],
|
||||
images: [],
|
||||
});
|
||||
expect(prompt).toEqual({ text: "look at @src/a.rs:3-5 and @b.rs", files: ["/w/src/a.rs", "/w/b.rs"], images: [] });
|
||||
expect(prompt).toEqual({
|
||||
text: "look at @src/a.rs:3-5 and @b.rs",
|
||||
files: ["/w/src/a.rs", "/w/b.rs"],
|
||||
images: [],
|
||||
});
|
||||
expect(mentionOf("a.rs", { from: 3, to: 3 })).toBe("@a.rs:3");
|
||||
// A folder counts while `@src/` is left as typed, not once a file inside it was picked.
|
||||
const folder = { path: "/w/src", rel: "src/" };
|
||||
@@ -182,7 +205,10 @@ describe("agent composer", () => {
|
||||
const permissions = option("allow_all", ["off", "on"], "off");
|
||||
permissions.category = "permissions";
|
||||
expect(mainOptions([...config, permissions]).map((o) => o.id)).toEqual(["model", "mode", permissions.id]);
|
||||
const approvals = { ...option("openidea.cursor.approval", ["ask", "bypass"], "ask"), category: "approval" };
|
||||
const approvals = {
|
||||
...option("openidea.cursor.approval", ["ask", "bypass"], "ask"),
|
||||
category: "approval",
|
||||
};
|
||||
expect(mainOptions([...config, permissions, approvals]).map((o) => o.id)).toEqual([
|
||||
"model",
|
||||
"mode",
|
||||
@@ -203,7 +229,9 @@ describe("agent composer", () => {
|
||||
"https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||
].map(modeIcon),
|
||||
).toEqual(["lock", "edit", "todo", "run", "unlock", "edit", "eye", "lock", "run", "run"]);
|
||||
expect(bypassDefaults([...config, permissions])).toEqual({ allow_all: "on" });
|
||||
expect(bypassDefaults([...config, permissions])).toEqual({
|
||||
allow_all: "on",
|
||||
});
|
||||
expect(usageLabel({ used: 24_000, size: 200_000 })).toEqual({
|
||||
short: "12%",
|
||||
detail: "24k / 200k tokens",
|
||||
@@ -224,14 +252,30 @@ describe("usage meters", () => {
|
||||
|
||||
it("names usage-limit windows and says when they reset", () => {
|
||||
const hour = 3_600_000;
|
||||
expect(limitLabel({ window: "five_hour", usedPercent: 19.4, resetsMs: 2 * hour + 51 * 60_000 }, 0)).toEqual({
|
||||
expect(
|
||||
limitLabel(
|
||||
{
|
||||
window: "five_hour",
|
||||
usedPercent: 19.4,
|
||||
resetsMs: 2 * hour + 51 * 60_000,
|
||||
},
|
||||
0,
|
||||
),
|
||||
).toEqual({
|
||||
short: "5h 19%",
|
||||
percent: 19,
|
||||
detail: "5-hour limit: 19% used, resets in 2h 51m",
|
||||
});
|
||||
expect(limitLabel({ window: "seven_day", usedPercent: 120, resetsMs: 5 * 24 * hour + 3 * hour }, 0).detail).toBe(
|
||||
"Weekly limit: 100% used, resets in 5d 3h",
|
||||
);
|
||||
expect(
|
||||
limitLabel(
|
||||
{
|
||||
window: "seven_day",
|
||||
usedPercent: 120,
|
||||
resetsMs: 5 * 24 * hour + 3 * hour,
|
||||
},
|
||||
0,
|
||||
).detail,
|
||||
).toBe("Weekly limit: 100% used, resets in 5d 3h");
|
||||
expect(limitLabel({ window: "spend_limit", usedPercent: 7, resetsMs: null }, 0)).toEqual({
|
||||
short: "spend_limit 7%",
|
||||
percent: 7,
|
||||
@@ -282,6 +326,22 @@ describe("agents model", () => {
|
||||
expect(upsertEntry([a], 3, b)).toBeNull();
|
||||
});
|
||||
|
||||
it("appends streamed text from where the copy here stands", () => {
|
||||
const u: AgentEntry = { type: "user", text: "q", attachments: [] };
|
||||
const a: AgentEntry = { type: "agent", text: "He" };
|
||||
expect(appendEntry([u, a], 1, 2, "llo")).toEqual([u, { type: "agent", text: "Hello" }]);
|
||||
// Loaded while it streamed: part of the text is already here.
|
||||
expect(appendEntry([u, { type: "agent", text: "Hel" }], 1, 2, "llo")?.[1]).toEqual({
|
||||
type: "agent",
|
||||
text: "Hello",
|
||||
});
|
||||
const whole = [u, { type: "agent", text: "Hello" } as AgentEntry];
|
||||
expect(appendEntry(whole, 1, 2, "llo")).toBe(whole);
|
||||
expect(appendEntry([u, { type: "agent", text: "H" }], 1, 2, "llo")).toBeNull();
|
||||
expect(appendEntry([u, a], 0, 0, "x")).toBeNull();
|
||||
expect(appendEntry([u, a], 2, 0, "x")).toBeNull();
|
||||
});
|
||||
|
||||
it("notifies on finish, failure and questions only", () => {
|
||||
expect(attentionChange("running", "ready")).toBe("finished");
|
||||
expect(attentionChange("running", "failed")).toBe("failed");
|
||||
@@ -365,9 +425,17 @@ describe("turns", () => {
|
||||
|
||||
describe("retryParts", () => {
|
||||
it("counts down to the next attempt and shows the limit when known", () => {
|
||||
const retry = { attempt: 3, maxRetries: 10, retryAtMs: 5_400, error: "Connection error." };
|
||||
const retry = {
|
||||
attempt: 3,
|
||||
maxRetries: 10,
|
||||
retryAtMs: 5_400,
|
||||
error: "Connection error.",
|
||||
};
|
||||
expect(retryParts(retry, 1_000)).toEqual({ attempt: "3/10", wait: 5 });
|
||||
expect(retryParts({ ...retry, maxRetries: null }, 9_000)).toEqual({ attempt: "3", wait: 0 });
|
||||
expect(retryParts({ ...retry, maxRetries: null }, 9_000)).toEqual({
|
||||
attempt: "3",
|
||||
wait: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -385,3 +453,30 @@ describe("typed path references", () => {
|
||||
expect(resolveTyped("C:\\r", "../../x")).toBe("C:/x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("new task defaults", () => {
|
||||
it("start a draft with the agent's defaults until the draft picks", async () => {
|
||||
const { draftConfig } = await import("./store");
|
||||
const option = (id: string, current: string, values: string[]) =>
|
||||
({
|
||||
id,
|
||||
name: id,
|
||||
description: null,
|
||||
category: id,
|
||||
current,
|
||||
values: values.map((value) => ({ value, name: value, description: null })),
|
||||
via: "option",
|
||||
}) as AgentConfigOption;
|
||||
const provider = {
|
||||
id: "claude",
|
||||
config: [option("model", "sonnet", ["sonnet", "opus"]), option("mode", "default", ["default", "plan"])],
|
||||
} as unknown as Parameters<typeof draftConfig>[0];
|
||||
const draft = { provider: "claude", config: {}, isolation: "local", base: null } as unknown as Parameters<
|
||||
typeof draftConfig
|
||||
>[1];
|
||||
const current = (d: typeof draft, defaults: Record<string, string>) =>
|
||||
draftConfig(provider, d, defaults).map((o) => o.current);
|
||||
expect(current(draft, { model: "opus", mode: "nope" })).toEqual(["opus", "default"]);
|
||||
expect(current({ ...draft, config: { model: "sonnet" } }, { model: "opus" })).toEqual(["sonnet", "default"]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendKeyAction } from "./Composer";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { AgentCommand, AgentProvider } from "@/bindings";
|
||||
@@ -283,3 +284,23 @@ describe("workspace task project", () => {
|
||||
await vi.dynamicImportSettled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendKeyAction", () => {
|
||||
const key = (k: Partial<{ shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }> = {}) => ({
|
||||
key: "Enter",
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
...k,
|
||||
});
|
||||
it("sends with Enter, or with the modifier, the way the settings say", () => {
|
||||
expect(sendKeyAction(key(), "enter", "queue")).toBe("queue");
|
||||
expect(sendKeyAction(key({ metaKey: true }), "enter", "queue")).toBe("now");
|
||||
expect(sendKeyAction(key({ shiftKey: true }), "enter", "queue")).toBeNull();
|
||||
expect(sendKeyAction(key(), "enter", "steer")).toBe("now");
|
||||
expect(sendKeyAction(key(), "modEnter", "queue")).toBeNull();
|
||||
expect(sendKeyAction(key({ ctrlKey: true }), "modEnter", "queue")).toBe("queue");
|
||||
expect(sendKeyAction(key({ metaKey: true, shiftKey: true }), "modEnter", "queue")).toBe("now");
|
||||
expect(sendKeyAction({ ...key(), key: "a" }, "enter", "queue")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -19,21 +19,22 @@ import {
|
||||
toolWindowService,
|
||||
} from "@/workbench/contributions";
|
||||
import { AgentsStatus } from "./AgentsStatus";
|
||||
import { HOME_KIND, REGISTRY_PAGE, SESSION_KIND, TASK_DRAG, TOOL_WINDOW } from "./model";
|
||||
import { AGENTS_GROUP, HOME_KIND, REGISTRY_PAGE, SESSION_KIND, TASK_DRAG, TOOL_WINDOW, providerIcon } from "./model";
|
||||
import { sendPaths, sendSelection } from "./references";
|
||||
import { focusedSession } from "./sessionFocus";
|
||||
import { connect, disconnect, openHome, useAgents } from "./store";
|
||||
import { TaskStatusMark } from "./TaskStatusMark";
|
||||
import { taskActions } from "./taskActions";
|
||||
import { taskActions, useAgentsHeaderMenu } from "./taskActions";
|
||||
import { replyActions } from "./replyActions";
|
||||
|
||||
/** Settings › Tools › Agents (also used by the Settings window). */
|
||||
/** Settings › Agents › Coding Agents (also used by the Settings window). */
|
||||
export function activateSettingsPages(): () => void {
|
||||
return registerSettingsPage({
|
||||
id: REGISTRY_PAGE,
|
||||
title: "Agents",
|
||||
group: "Tools",
|
||||
order: 500,
|
||||
title: "Coding Agents",
|
||||
group: AGENTS_GROUP,
|
||||
// First in Agents, which sits after Languages & Frameworks and before Tools.
|
||||
order: 450,
|
||||
keywords: ["agent", "acp", "registry", "install", "update", "coding agent"],
|
||||
component: lazyComponent(() => import("./AgentRegistryPage").then((m) => m.AgentRegistryPage)),
|
||||
});
|
||||
@@ -73,7 +74,8 @@ export function activate(): () => void {
|
||||
anchor: "right",
|
||||
order: 0.5,
|
||||
component: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsView)),
|
||||
headerContent: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsLocationActions)),
|
||||
headerMenu: useAgentsHeaderMenu,
|
||||
plainTitle: true,
|
||||
headerActions: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsHeaderActions)),
|
||||
useBadge: () => {
|
||||
const n = useAgents(
|
||||
@@ -89,7 +91,10 @@ export function activate(): () => void {
|
||||
const data = input.data as { task: string; title?: string };
|
||||
return useAgents.getState().tasks.get(data.task)?.title ?? data.title ?? "Agent Task";
|
||||
},
|
||||
icon: () => "toolwindow-agents",
|
||||
icon: (input) => {
|
||||
const task = useAgents.getState().tasks.get((input.data as { task: string }).task);
|
||||
return task ? providerIcon(task.provider) : "toolwindow-agents";
|
||||
},
|
||||
// Busy / done marker in the tab's trailing slot (#2233).
|
||||
trailing: (input) => createElement(TaskStatusMark, { task: (input.data as { task: string }).task }),
|
||||
// Tabs of tasks deleted meanwhile are closed when the tasks load (`connect`).
|
||||
@@ -101,7 +106,10 @@ export function activate(): () => void {
|
||||
kind: HOME_KIND,
|
||||
component: lazyComponent(() => import("./HomeEditor").then((m) => m.HomeEditor)),
|
||||
title: () => "New Agent Task",
|
||||
icon: () => "toolwindow-agents",
|
||||
icon: (input) => {
|
||||
const task = useAgents.getState().tasks.get((input.data as { task: string }).task);
|
||||
return task ? providerIcon(task.provider) : "toolwindow-agents";
|
||||
},
|
||||
restorable: true,
|
||||
}),
|
||||
// An explicit editor drop sets the placement for subsequent sessions too.
|
||||
|
||||
@@ -7,8 +7,10 @@ export const HOME_KEY = "agent:new";
|
||||
export const TOOL_WINDOW = "agents";
|
||||
/** MIME of a dragged agent task (a tree row or the docked conversation bar): the payload is the task id. */
|
||||
export const TASK_DRAG = "application/x-openidea-agent-task";
|
||||
/** Settings › Tools › Agents: the ACP Registry. */
|
||||
export const REGISTRY_PAGE = "tools.agents";
|
||||
/** Settings › Agents › Coding Agents: the agents this machine runs. */
|
||||
export const REGISTRY_PAGE = "agents.list";
|
||||
/** The Settings folder holding the agent pages. */
|
||||
export const AGENTS_GROUP = "Agents";
|
||||
|
||||
export const sessionKey = (task: string) => `agent:${task}`;
|
||||
|
||||
@@ -95,6 +97,23 @@ export function upsertEntry(entries: AgentEntry[], index: number, entry: AgentEn
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* `entries` with `text` appended to the message at `index`, which read its first `from`
|
||||
* UTF-16 units when the backend last sent it; `null` when the copy here can't take it (a
|
||||
* gap, or another kind of entry), so the conversation is loaded again. A copy loaded while
|
||||
* the text streamed may already hold some of it.
|
||||
*/
|
||||
export function appendEntry(entries: AgentEntry[], index: number, from: number, text: string): AgentEntry[] | null {
|
||||
const entry = entries[index];
|
||||
if (!entry || (entry.type !== "agent" && entry.type !== "thought")) return null;
|
||||
const have = entry.text.length;
|
||||
if (have < from) return null;
|
||||
if (have >= from + text.length) return entries;
|
||||
const next = entries.slice();
|
||||
next[index] = { ...entry, text: entry.text + text.slice(have - from) };
|
||||
return next;
|
||||
}
|
||||
|
||||
/** "agent/fix-lexer" → "fix-lexer". */
|
||||
export const branchLabel = (branch: string | null) => (branch ?? "").replace(/^agent\//, "");
|
||||
|
||||
@@ -144,7 +163,10 @@ export function replyTimes(entries: AgentEntry[]): Map<number, ReplyTimes> {
|
||||
entries.forEach((entry, i) => {
|
||||
if (entry.type === "user" && !entry.steered) current = entry;
|
||||
else if (entry.type === "agent" && current)
|
||||
out.set(i, { sentMs: current.sentMs ?? null, doneMs: current.doneMs ?? null });
|
||||
out.set(i, {
|
||||
sentMs: current.sentMs ?? null,
|
||||
doneMs: current.doneMs ?? null,
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ export const replyActions: Action[] = [
|
||||
},
|
||||
{
|
||||
id: "agents.forkReply",
|
||||
title: "Branch Conversation from Here",
|
||||
title: "Fork Conversation from Here",
|
||||
category: "Agents",
|
||||
icon: "branch",
|
||||
hidden: true,
|
||||
@@ -140,7 +140,7 @@ export const replyActions: Action[] = [
|
||||
const resolved = resolveReply(arg);
|
||||
if (!resolved?.operations.fork) return;
|
||||
const { task, reply } = resolved;
|
||||
return runReplyOperation(task.id, "Couldn't copy the task", () => forkTask(task.id, reply.turn + 1));
|
||||
return runReplyOperation(task.id, "Couldn't fork the task", () => forkTask(task.id, reply.turn + 1));
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,70 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { REVEAL_HORIZON_MS, revealStep, useRevealedText } from "./reveal";
|
||||
|
||||
describe("revealStep", () => {
|
||||
it("drains the backlog over the horizon, at least a character a frame", () => {
|
||||
expect(revealStep(0, 16)).toBe(0);
|
||||
expect(revealStep(300, 15)).toBe(30);
|
||||
expect(revealStep(3, 1)).toBe(1);
|
||||
expect(revealStep(300, REVEAL_HORIZON_MS)).toBe(300);
|
||||
expect(revealStep(300, 10_000)).toBe(300);
|
||||
expect(revealStep(300, 0)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useRevealedText", () => {
|
||||
let frames: FrameRequestCallback[] = [];
|
||||
let now = 0;
|
||||
beforeEach(() => {
|
||||
frames = [];
|
||||
now = 0;
|
||||
vi.spyOn(performance, "now").mockImplementation(() => now);
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
|
||||
vi.stubGlobal("cancelAnimationFrame", () => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
const frame = (ms: number) =>
|
||||
act(() => {
|
||||
now += ms;
|
||||
const run = frames;
|
||||
frames = [];
|
||||
run.forEach((cb) => cb(now));
|
||||
});
|
||||
|
||||
it("reveals appended text over frames while live", () => {
|
||||
const { result, rerender } = renderHook(({ text, live }) => useRevealedText(text, live), {
|
||||
initialProps: { text: "Hi", live: true },
|
||||
});
|
||||
expect(result.current).toBe("Hi");
|
||||
rerender({ text: `Hi${"x".repeat(100)}`, live: true });
|
||||
expect(result.current).toBe("Hi");
|
||||
frame(15);
|
||||
expect(result.current.length).toBe(12);
|
||||
frame(200);
|
||||
expect(result.current.length).toBe(102);
|
||||
});
|
||||
|
||||
it("never paints half an emoji", () => {
|
||||
const { result, rerender } = renderHook(({ text }) => useRevealedText(text, true), {
|
||||
initialProps: { text: "a" },
|
||||
});
|
||||
rerender({ text: "a👋" });
|
||||
frame(1);
|
||||
expect(result.current).toBe("a👋");
|
||||
});
|
||||
|
||||
it("shows text whole once it stops streaming or is replaced", () => {
|
||||
const { result, rerender } = renderHook(({ text, live }) => useRevealedText(text, live), {
|
||||
initialProps: { text: "Hi", live: true },
|
||||
});
|
||||
rerender({ text: "Hi there", live: true });
|
||||
rerender({ text: "Hi there", live: false });
|
||||
expect(result.current).toBe("Hi there");
|
||||
rerender({ text: "Other", live: true });
|
||||
expect(result.current).toBe("Other");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Paced reveal of a streaming reply. Chunks arrive in lumps (one flush per 40 ms, holding
|
||||
* whatever the model produced meanwhile); painting each as it lands makes the lumps show.
|
||||
* Arrival moves the target instead, and each frame reveals a share of the backlog, so a
|
||||
* burst catches up faster rather than jumping. The store keeps the whole text: only what
|
||||
* is painted is paced.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** The backlog is drained over this long. Longer reads smoother but trails the model. */
|
||||
export const REVEAL_HORIZON_MS = 150;
|
||||
/** A frame stalled longer than this (a background tab) counts as this long. */
|
||||
const MAX_FRAME_MS = 250;
|
||||
|
||||
/** Characters to reveal in a frame `elapsed` ms after the last, at least one while behind. */
|
||||
export function revealStep(backlog: number, elapsed: number, horizon = REVEAL_HORIZON_MS): number {
|
||||
if (backlog <= 0) return 0;
|
||||
const ms = Math.min(Math.max(elapsed, 0), MAX_FRAME_MS);
|
||||
if (ms >= horizon) return backlog;
|
||||
return Math.min(backlog, Math.max(1, Math.ceil((backlog * ms) / horizon)));
|
||||
}
|
||||
|
||||
/** `count` moved past a lone UTF-16 high surrogate, so an emoji never paints half. */
|
||||
function boundary(text: string, count: number): number {
|
||||
const c = text.charCodeAt(count - 1);
|
||||
return c >= 0xd800 && c <= 0xdbff ? Math.min(count + 1, text.length) : count;
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of `text` to paint. While `live`, appended text is revealed at a pace; a text
|
||||
* that isn't the painted one continued (another message, an edit), or one that stops
|
||||
* being live, is shown whole at once.
|
||||
*/
|
||||
export function useRevealedText(text: string, live: boolean): string {
|
||||
const [state, setState] = useState({ text, count: text.length });
|
||||
let count = state.count;
|
||||
if (state.text !== text || (!live && count !== text.length)) {
|
||||
count = live && text.startsWith(state.text.slice(0, count)) ? Math.min(count, text.length) : text.length;
|
||||
setState({ text, count });
|
||||
}
|
||||
const behind = count < text.length;
|
||||
useEffect(() => {
|
||||
if (!behind) return;
|
||||
let last = performance.now();
|
||||
let frame = requestAnimationFrame(function tick(now) {
|
||||
const elapsed = now - last;
|
||||
last = now;
|
||||
setState((s) => ({
|
||||
...s,
|
||||
count: boundary(s.text, s.count + revealStep(s.text.length - s.count, elapsed)),
|
||||
}));
|
||||
frame = requestAnimationFrame(tick);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [behind]);
|
||||
return behind ? text.slice(0, count) : text;
|
||||
}
|
||||
@@ -110,6 +110,7 @@ afterEach(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
disposeActions = registerActions([...replyActions, ...taskActions]);
|
||||
useProject.setState({ settings: null });
|
||||
backend.entryError = null;
|
||||
backend.entryWait = null;
|
||||
disconnect();
|
||||
@@ -131,6 +132,7 @@ beforeEach(() => {
|
||||
|
||||
describe("conversation rows", () => {
|
||||
it("keeps authors above messages and their folded work with matching icon-only actions", async () => {
|
||||
useProject.setState({ settings: { agents: { foldTurns: false } } as never });
|
||||
const user = (text: string): AgentEntry => ({
|
||||
type: "user",
|
||||
text,
|
||||
@@ -158,7 +160,8 @@ describe("conversation rows", () => {
|
||||
expect(author.parentElement?.nextElementSibling?.firstElementChild).toHaveClass(styles.agentMessage!);
|
||||
const quote = within(stream()).getAllByRole("button", { name: "Quote Reply" });
|
||||
const copy = within(stream()).getAllByRole("button", { name: "Copy Markdown" });
|
||||
expect(quote).toHaveLength(3);
|
||||
// One per turn: only a turn's final reply carries actions.
|
||||
expect(quote).toHaveLength(2);
|
||||
for (const button of [...quote, ...copy]) {
|
||||
expect(button.textContent).toBe("");
|
||||
expect(button.querySelector("svg path")).not.toBeNull();
|
||||
@@ -209,7 +212,8 @@ describe("conversation rows", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("anchors every agent footer with its time before the hover actions", async () => {
|
||||
it("puts the time and hover actions under a turn's final reply only", async () => {
|
||||
useProject.setState({ settings: { agents: { foldTurns: false } } as never });
|
||||
backend.entries.set("A", [
|
||||
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: 13000 },
|
||||
{ type: "agent", text: "First reply" },
|
||||
@@ -219,17 +223,19 @@ describe("conversation rows", () => {
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Final reply");
|
||||
const messages = stream().querySelectorAll(`.${styles.agentMessage}`);
|
||||
expect(messages).toHaveLength(2);
|
||||
for (const message of messages) {
|
||||
const [card, footer] = message.children;
|
||||
expect(card).toHaveClass(styles.agentCard!);
|
||||
expect(footer).toHaveClass(styles.messageFooter!);
|
||||
expect(footer?.firstElementChild).toHaveClass(styles.messageTime!);
|
||||
expect(footer?.firstElementChild).toHaveTextContent("12s");
|
||||
expect(footer?.lastElementChild).toHaveClass(styles.messageActions!);
|
||||
}
|
||||
expect(stream().querySelector(`.${styles.userMessage} > .${styles.messageFooter}`)).not.toBeNull();
|
||||
const [first, last] = stream().querySelectorAll(`.${styles.agentMessage}`);
|
||||
expect(first?.children).toHaveLength(1);
|
||||
const [card, footer] = last!.children;
|
||||
expect(card).toHaveClass(styles.agentCard!);
|
||||
expect(footer).toHaveClass(styles.messageFooter!);
|
||||
expect(footer?.firstElementChild).toHaveClass(styles.messageTime!);
|
||||
expect(footer?.firstElementChild).toHaveTextContent("12s");
|
||||
expect(footer?.lastElementChild).toHaveClass(styles.messageActions!);
|
||||
// Your message keeps its actions and time beside "You", above it.
|
||||
expect(stream().querySelector(`.${styles.userMessage} > .${styles.messageFooter}`)).toBeNull();
|
||||
expect(
|
||||
stream().querySelector(`.${styles.userMessage} > .${styles.messageHead} .${styles.messageActions}`),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("builds only the requested tail, with absolute indexes", () => {
|
||||
@@ -251,7 +257,7 @@ describe("process folding", () => {
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const summary = await screen.findByText("Reasoning · 2 tool calls");
|
||||
const summary = await screen.findByText("Reasoning · Read 2 files");
|
||||
const fold = summary.closest("details")!;
|
||||
expect(fold.open).toBe(false);
|
||||
// Folded content is rendered once opened, a level at a time.
|
||||
@@ -268,6 +274,50 @@ describe("process folding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("turn folding", () => {
|
||||
it("folds a finished turn's work under its message and keeps the reply", async () => {
|
||||
backend.entries.set("A", [
|
||||
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: 13000 },
|
||||
{ type: "thought", text: "think" },
|
||||
tool("one"),
|
||||
{ type: "agent", text: "Interim note" },
|
||||
tool("two", { kind: "execute" }),
|
||||
{ type: "agent", text: "Final reply" },
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByText("Final reply");
|
||||
const summary = screen.getByText("Reasoning · Ran 1 command · Read 1 file · 1 message");
|
||||
const fold = summary.closest("details")!;
|
||||
expect(fold).toHaveClass(styles.turnFold!);
|
||||
expect(fold).toHaveTextContent("12s");
|
||||
expect(screen.queryByText("Interim note")).toBeNull();
|
||||
fold.open = true;
|
||||
fireEvent(fold, new Event("toggle"));
|
||||
expect(await screen.findByText("Interim note")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves the turn the agent works on open", async () => {
|
||||
backend.entries.set("A", [
|
||||
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: null },
|
||||
tool("one"),
|
||||
{ type: "agent", text: "Interim note" },
|
||||
]);
|
||||
useAgents.setState({
|
||||
entries: new Map(backend.entries),
|
||||
tasks: new Map(useAgents.getState().tasks).set("A", {
|
||||
...useAgents.getState().tasks.get("A")!,
|
||||
status: "running",
|
||||
}),
|
||||
});
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
expect(await screen.findByText("Interim note")).toBeInTheDocument();
|
||||
expect(stream().querySelector(`.${styles.turnFold}`)).toBeNull();
|
||||
// The turn isn't over: its latest message is no final reply yet.
|
||||
expect(stream().querySelector(`.${styles.agentMessage} > .${styles.messageFooter}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("folded content", () => {
|
||||
it("is rendered for Find in Conversation, so its text is found", async () => {
|
||||
backend.entries.set("A", [
|
||||
@@ -277,7 +327,7 @@ describe("folded content", () => {
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const summary = await screen.findByText("2 tool calls");
|
||||
const summary = await screen.findByText("Read 2 files");
|
||||
expect(screen.queryByText("needle in a tool output")).toBeNull();
|
||||
fireEvent.focus(summary);
|
||||
act(() => focusedSession()!.openFind());
|
||||
@@ -432,7 +482,7 @@ describe("tool calls", () => {
|
||||
const output = await screen.findByText("Reading files…");
|
||||
expect(output.closest("details")!.open).toBe(true);
|
||||
expect(output.closest(`.${styles.processRun}`)).toBeNull();
|
||||
expect(screen.getByText("2 tool calls").closest("details")!.open).toBe(false);
|
||||
expect(screen.getByText("Read 2 files").closest("details")!.open).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -441,7 +491,7 @@ describe("tool calls", () => {
|
||||
backend.entries.set("A", [edit("one"), edit("two"), { type: "agent", text: "Answer A" }]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const summary = await screen.findByText("2 tool calls");
|
||||
const summary = await screen.findByText("Edited 1 file");
|
||||
expect(summary.closest("details")!.open).toBe(false);
|
||||
expect(screen.getByText("+4")).toBeInTheDocument();
|
||||
expect(screen.getByText("−2")).toBeInTheDocument();
|
||||
@@ -640,7 +690,7 @@ describe("a pending AskUserQuestion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pending permission request in the conversation", () => {
|
||||
describe("a pending permission request above the composer", () => {
|
||||
const command = 'python3 -c \'from pathlib import Path\np = Path("note.txt")\np.write_text("one\\ntwo")\'';
|
||||
const options = [
|
||||
{ id: "opaque-once", name: "Allow Once", kind: "allow_once" },
|
||||
@@ -668,7 +718,8 @@ describe("a pending permission request in the conversation", () => {
|
||||
const card = await screen.findByRole("region", { name: "codex wants to run a command" });
|
||||
expect(screen.getAllByRole("region", { name: "codex wants to run a command" })).toHaveLength(1);
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(stream()).toContainElement(card);
|
||||
expect(stream()).not.toContainElement(card);
|
||||
expect(card.closest(`.${styles.tray}`)).not.toBeNull();
|
||||
expect(card.querySelector("pre")?.textContent).toBe(command);
|
||||
const buttons = within(card).getAllByRole("button");
|
||||
expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual([
|
||||
@@ -682,7 +733,7 @@ describe("a pending permission request in the conversation", () => {
|
||||
expect(buttons[1]?.title).toBe(options[1]?.name);
|
||||
});
|
||||
|
||||
it.each(options)("sends the original $kind option id, then collapses the resolved card", async (option) => {
|
||||
it.each(options)("sends the original $kind option id, then records it in the conversation", async (option) => {
|
||||
const pending: AgentEntry = { type: "permission", id: 12, title: `Run ${command}`, options, chosen: null };
|
||||
backend.answers = [];
|
||||
backend.entries.set("A", [pending]);
|
||||
@@ -696,11 +747,31 @@ describe("a pending permission request in the conversation", () => {
|
||||
act(() =>
|
||||
useAgents.setState({ entries: new Map(backend.entries).set("A", [{ ...pending, chosen: option.name }]) }),
|
||||
);
|
||||
expect(card.querySelector("pre")).toBeNull();
|
||||
expect(within(card).queryByRole("button")).toBeNull();
|
||||
expect(card.textContent).toContain("python3 …");
|
||||
expect(card.textContent).not.toContain("write_text");
|
||||
expect(card).toHaveFocus();
|
||||
expect(card).not.toBeInTheDocument();
|
||||
const record = within(stream()).getByRole("region", { name: "codex wants to run a command" });
|
||||
expect(record.querySelector("pre")).toBeNull();
|
||||
expect(within(record).queryByRole("button")).toBeNull();
|
||||
expect(record.textContent).toContain("python3 …");
|
||||
expect(record.textContent).not.toContain("write_text");
|
||||
await waitFor(() => expect(document.activeElement?.tagName).toBe("TEXTAREA"));
|
||||
});
|
||||
|
||||
it("is answered with a number key when the focus isn't in a text field", async () => {
|
||||
const pending: AgentEntry = { type: "permission", id: 12, title: `Run ${command}`, options, chosen: null };
|
||||
backend.answers = [];
|
||||
backend.entries.set("A", [pending]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
await screen.findByRole("region", { name: "codex wants to run a command" });
|
||||
fireEvent.keyDown(document.querySelector("textarea")!, { key: "1" });
|
||||
expect(backend.answers).toEqual([]);
|
||||
const elsewhere = document.body.appendChild(document.createElement("div"));
|
||||
fireEvent.keyDown(elsewhere, { key: "1" });
|
||||
fireEvent.keyDown(document.body, { key: "2" });
|
||||
elsewhere.remove();
|
||||
expect(backend.answers).toEqual([]);
|
||||
fireEvent.keyDown(stream(), { key: "3" });
|
||||
await waitFor(() => expect(backend.answers).toEqual([["A", 12, "opaque-no"]]));
|
||||
});
|
||||
|
||||
it("is answered from its line too, and one without options can be cancelled", async () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
HOME_KIND,
|
||||
SESSION_KIND,
|
||||
TOOL_WINDOW,
|
||||
appendEntry,
|
||||
attentionChange,
|
||||
entriesKey,
|
||||
isBusy,
|
||||
@@ -107,7 +108,12 @@ function save(key: string, value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackDraft: Draft = { provider: "claude", config: {}, isolation: "local", base: null };
|
||||
const fallbackDraft: Draft = {
|
||||
provider: "claude",
|
||||
config: {},
|
||||
isolation: "local",
|
||||
base: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* The saved New Task settings. A worktree is used only when the user picked it: drafts
|
||||
@@ -151,20 +157,37 @@ export function updateDraft(patch: Partial<Draft>) {
|
||||
save(DRAFT_KEY, draft);
|
||||
}
|
||||
|
||||
/** Config for the selected model, with only supported draft choices applied. */
|
||||
export function draftConfig(provider: AgentProvider | undefined, draft: Draft): AgentConfigOption[] {
|
||||
/** What Settings › Agents says new tasks of `provider` start with, by option id. */
|
||||
export const providerDefaults = (provider: string | undefined): Record<string, string> =>
|
||||
(provider && useProject.getState().settings?.agents?.defaults?.[provider]) || {};
|
||||
|
||||
/**
|
||||
* Config for the selected model, with only supported choices applied: the draft's, else
|
||||
* the agent's defaults from Settings › Agents.
|
||||
*/
|
||||
export function draftConfig(
|
||||
provider: AgentProvider | undefined,
|
||||
draft: Draft,
|
||||
defaults: Record<string, string> = providerDefaults(provider?.id),
|
||||
): AgentConfigOption[] {
|
||||
const config = (provider?.config ?? [])
|
||||
.filter((o) => o.category !== "thought_level")
|
||||
.map((o) => {
|
||||
const choice = draft.config[o.id];
|
||||
return { ...o, current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current };
|
||||
const choice = draft.config[o.id] ?? defaults[o.id];
|
||||
return {
|
||||
...o,
|
||||
current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current,
|
||||
};
|
||||
});
|
||||
const model = config.find((o) => o.category === "model")?.current;
|
||||
if (model) {
|
||||
config.push(
|
||||
...(provider?.thoughtLevels?.[model] ?? []).map((o) => {
|
||||
const choice = draft.thoughtLevels?.[model]?.[o.id];
|
||||
return { ...o, current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current };
|
||||
const choice = draft.thoughtLevels?.[model]?.[o.id] ?? defaults[o.id];
|
||||
return {
|
||||
...o,
|
||||
current: choice !== undefined && o.values.some((v) => v.value === choice) ? choice : o.current,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -180,7 +203,10 @@ export function setDraftOption(id: string, value: string) {
|
||||
const model = config.find((o) => o.category === "model")?.current;
|
||||
if (model && config.find((o) => o.id === id)?.category === "thought_level") {
|
||||
updateDraft({
|
||||
thoughtLevels: { ...draft.thoughtLevels, [model]: { ...draft.thoughtLevels?.[model], [id]: value } },
|
||||
thoughtLevels: {
|
||||
...draft.thoughtLevels,
|
||||
[model]: { ...draft.thoughtLevels?.[model], [id]: value },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
updateDraft({ config: { ...draft.config, [id]: value } });
|
||||
@@ -191,7 +217,10 @@ export const composerDraft = (key: string) => useAgents.getState().composers.get
|
||||
|
||||
export function updateComposer(key: string, patch: Partial<ComposerDraft>) {
|
||||
useAgents.setState((s) => ({
|
||||
composers: new Map(s.composers).set(key, { ...(s.composers.get(key) ?? emptyDraft()), ...patch }),
|
||||
composers: new Map(s.composers).set(key, {
|
||||
...(s.composers.get(key) ?? emptyDraft()),
|
||||
...patch,
|
||||
}),
|
||||
lastComposer: key,
|
||||
}));
|
||||
}
|
||||
@@ -251,7 +280,11 @@ export async function connect(): Promise<void> {
|
||||
: call(commands.agentProviders()),
|
||||
]);
|
||||
} catch (e) {
|
||||
if (gen === generation) useAgents.setState({ loaded: true, error: e instanceof Error ? e.message : String(e) });
|
||||
if (gen === generation)
|
||||
useAgents.setState({
|
||||
loaded: true,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (gen !== generation) return;
|
||||
@@ -274,8 +307,46 @@ export async function connect(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
type EntryEvent = Extract<AgentEvent, { type: "entry" | "append" }>;
|
||||
|
||||
/** Entry events that arrived while their conversation loaded, applied on top of the copy it loads. */
|
||||
const arrivedWhileLoading = new Map<string, EntryEvent[]>();
|
||||
/** Whether the message each task streams now is the agent's reply, known without its conversation. */
|
||||
const streamingReply = new Map<string, { index: number; reply: boolean }>();
|
||||
|
||||
/** `entries` with the event applied; `null` when the copy can't take it. */
|
||||
function applyEntryEvent(entries: AgentEntry[], e: EntryEvent): AgentEntry[] | null {
|
||||
if (e.type === "append") return appendEntry(entries, e.index, e.from, e.text);
|
||||
const have = entries[e.index];
|
||||
// Sent before the loaded copy was read: that copy already holds more of the message.
|
||||
if (
|
||||
have &&
|
||||
(have.type === "agent" || have.type === "thought") &&
|
||||
e.entry.type === have.type &&
|
||||
have.text.length > e.entry.text.length &&
|
||||
have.text.startsWith(e.entry.text)
|
||||
)
|
||||
return entries;
|
||||
return upsertEntry(entries, e.index, e.entry);
|
||||
}
|
||||
|
||||
function onEntryEvent(s: AgentsState, e: EntryEvent) {
|
||||
const key = entriesKey(e.task, e.subagent);
|
||||
if (s.entryLoads.get(key)?.error === null) {
|
||||
arrivedWhileLoading.set(key, [...(arrivedWhileLoading.get(key) ?? []), e]);
|
||||
return;
|
||||
}
|
||||
const current = s.entries.get(key);
|
||||
if (!current) return;
|
||||
const next = applyEntryEvent(current, e);
|
||||
if (!next) void loadEntries(e.task, e.subagent);
|
||||
else if (next !== current) useAgents.setState({ entries: new Map(s.entries).set(key, next) });
|
||||
}
|
||||
|
||||
export function disconnect() {
|
||||
generation++;
|
||||
arrivedWhileLoading.clear();
|
||||
streamingReply.clear();
|
||||
useAgents.setState({
|
||||
loaded: false,
|
||||
error: null,
|
||||
@@ -314,13 +385,21 @@ function onEvent(e: AgentEvent) {
|
||||
composers.delete(e.id);
|
||||
const unread = new Set(s.unread);
|
||||
unread.delete(e.id);
|
||||
useAgents.setState({ tasks, composers, unread, ...(s.docked === e.id ? { docked: null } : {}) });
|
||||
useAgents.setState({
|
||||
tasks,
|
||||
composers,
|
||||
unread,
|
||||
...(s.docked === e.id ? { docked: null } : {}),
|
||||
});
|
||||
void editorService().closeInput?.(sessionKey(e.id));
|
||||
break;
|
||||
}
|
||||
case "truncated": {
|
||||
const current = s.entries.get(e.task);
|
||||
if (current) useAgents.setState({ entries: new Map(s.entries).set(e.task, current.slice(0, e.length)) });
|
||||
if (current)
|
||||
useAgents.setState({
|
||||
entries: new Map(s.entries).set(e.task, current.slice(0, e.length)),
|
||||
});
|
||||
scheduleChanges(e.task);
|
||||
break;
|
||||
}
|
||||
@@ -329,7 +408,9 @@ function onEvent(e: AgentEvent) {
|
||||
break;
|
||||
}
|
||||
case "limits": {
|
||||
useAgents.setState({ limits: new Map(s.limits).set(e.provider, e.limits) });
|
||||
useAgents.setState({
|
||||
limits: new Map(s.limits).set(e.provider, e.limits),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "entry": {
|
||||
@@ -341,17 +422,22 @@ function onEvent(e: AgentEvent) {
|
||||
) {
|
||||
markUnread(e.task);
|
||||
}
|
||||
const key = entriesKey(e.task, e.subagent);
|
||||
const current = s.entries.get(key);
|
||||
if (!current) return;
|
||||
const next = upsertEntry(current, e.index, e.entry);
|
||||
if (next) useAgents.setState({ entries: new Map(s.entries).set(key, next) });
|
||||
else void loadEntries(e.task, e.subagent);
|
||||
if (!e.subagent) streamingReply.set(e.task, { index: e.index, reply: e.entry.type === "agent" });
|
||||
onEntryEvent(s, e);
|
||||
if (e.entry.type === "tool" && e.entry.status === "completed" && e.entry.diffs.length) {
|
||||
scheduleChanges(e.task);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "append": {
|
||||
if (!e.subagent && e.text.trim()) {
|
||||
const shown = s.entries.get(entriesKey(e.task, null))?.[e.index];
|
||||
const streaming = streamingReply.get(e.task);
|
||||
if (shown ? shown.type === "agent" : streaming?.index === e.index && streaming.reply) markUnread(e.task);
|
||||
}
|
||||
onEntryEvent(s, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +458,12 @@ function notifyChange(prev: AgentTask | undefined, task: AgentTask) {
|
||||
key: `agent:${task.id}`,
|
||||
group: "tools",
|
||||
sticky: change !== "finished",
|
||||
actions: [{ label: change === "finished" ? "Review" : "Open", run: () => openTask(task.id) }],
|
||||
actions: [
|
||||
{
|
||||
label: change === "finished" ? "Review" : "Open",
|
||||
run: () => openTask(task.id),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -381,7 +472,9 @@ export async function loadEntries(task: string, subagent: string | null = null):
|
||||
const key = entriesKey(task, subagent);
|
||||
if (useAgents.getState().entryLoads.get(key)?.error === null) return;
|
||||
const load = { error: null };
|
||||
useAgents.setState((s) => ({ entryLoads: new Map(s.entryLoads).set(key, load) }));
|
||||
useAgents.setState((s) => ({
|
||||
entryLoads: new Map(s.entryLoads).set(key, load),
|
||||
}));
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const entries = await Promise.race([
|
||||
@@ -391,15 +484,21 @@ export async function loadEntries(task: string, subagent: string | null = null):
|
||||
}),
|
||||
]);
|
||||
if (useAgents.getState().entryLoads.get(key) !== load) return;
|
||||
let loaded = entries;
|
||||
for (const event of arrivedWhileLoading.get(key) ?? []) loaded = applyEntryEvent(loaded, event) ?? loaded;
|
||||
arrivedWhileLoading.delete(key);
|
||||
useAgents.setState((s) => {
|
||||
const entryLoads = new Map(s.entryLoads);
|
||||
entryLoads.delete(key);
|
||||
return { entries: new Map(s.entries).set(key, entries), entryLoads };
|
||||
return { entries: new Map(s.entries).set(key, loaded), entryLoads };
|
||||
});
|
||||
} catch (e) {
|
||||
if (useAgents.getState().entryLoads.get(key) !== load) return;
|
||||
arrivedWhileLoading.delete(key);
|
||||
const error = (e instanceof Error ? e.message : String(e)).trim() || tx("Unknown error");
|
||||
useAgents.setState((s) => ({ entryLoads: new Map(s.entryLoads).set(key, { error }) }));
|
||||
useAgents.setState((s) => ({
|
||||
entryLoads: new Map(s.entryLoads).set(key, { error }),
|
||||
}));
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
@@ -408,7 +507,9 @@ export async function loadEntries(task: string, subagent: string | null = null):
|
||||
export async function refreshChanges(task: string): Promise<void> {
|
||||
try {
|
||||
const changes = await call(commands.agentChanges(task));
|
||||
useAgents.setState((s) => ({ changes: new Map(s.changes).set(task, changes) }));
|
||||
useAgents.setState((s) => ({
|
||||
changes: new Map(s.changes).set(task, changes),
|
||||
}));
|
||||
} catch {
|
||||
// The worktree is gone or not a repository: nothing to list.
|
||||
useAgents.setState((s) => ({ changes: new Map(s.changes).set(task, []) }));
|
||||
@@ -445,7 +546,10 @@ export function useAgentCommands(
|
||||
task: string | null,
|
||||
offered: readonly AgentCommand[],
|
||||
): readonly AgentCommand[] {
|
||||
const [found, setFound] = useState<{ for: unknown[]; list: readonly AgentCommand[] } | null>(null);
|
||||
const [found, setFound] = useState<{
|
||||
for: unknown[];
|
||||
list: readonly AgentCommand[];
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
if (!provider || !isTauri()) return;
|
||||
let alive = true;
|
||||
@@ -477,7 +581,12 @@ function openTaskInEditor(task: string) {
|
||||
}
|
||||
|
||||
function showDockedTask(task: string) {
|
||||
useAgents.setState({ docked: task, lastTask: task, homeOpen: false, lastComposer: task });
|
||||
useAgents.setState({
|
||||
docked: task,
|
||||
lastTask: task,
|
||||
homeOpen: false,
|
||||
lastComposer: task,
|
||||
});
|
||||
toolWindowService().show(TOOL_WINDOW, true);
|
||||
void editorService().closeInput?.(sessionKey(task));
|
||||
}
|
||||
@@ -541,7 +650,10 @@ export function openHome(prompt?: string) {
|
||||
if (project?.workspace) {
|
||||
const previous = useAgents.getState().draft.projectRoot;
|
||||
const projectRoot = defaultTaskProject(project, editorService().activePath(), previous);
|
||||
updateDraft({ projectRoot, ...(projectRoot !== previous ? { base: null } : {}) });
|
||||
updateDraft({
|
||||
projectRoot,
|
||||
...(projectRoot !== previous ? { base: null } : {}),
|
||||
});
|
||||
}
|
||||
if (prompt !== undefined) updateComposer(HOME, { text: prompt });
|
||||
if (useAgents.getState().sessionLocation === "toolWindow") {
|
||||
@@ -565,7 +677,7 @@ export async function startTask(
|
||||
providers.find((p) => p.id === draft.provider),
|
||||
draft,
|
||||
);
|
||||
const defaults = bypassDefaults(options);
|
||||
const defaults = { ...bypassDefaults(options), ...providerDefaults(draft.provider) };
|
||||
const config = options.flatMap((o) => {
|
||||
const value =
|
||||
o.category === "model" || o.category === "thought_level"
|
||||
@@ -765,7 +877,10 @@ export function addReferences(refs: Array<{ path: string; rel: string; mention:
|
||||
const current = composerDraft(key);
|
||||
// A worktree task sees the same files under its own directory.
|
||||
const root = task?.isolation === "worktree" ? task.cwd : null;
|
||||
const files: FileRef[] = refs.map((r) => ({ path: root ? `${root}/${r.rel}` : r.path, rel: r.rel }));
|
||||
const files: FileRef[] = refs.map((r) => ({
|
||||
path: root ? `${root}/${r.rel}` : r.path,
|
||||
rel: r.rel,
|
||||
}));
|
||||
const mentions = refs.map((r) => r.mention).join(" ");
|
||||
const text =
|
||||
current.text && !/\s$/.test(current.text) ? `${current.text} ${mentions} ` : `${current.text}${mentions} `;
|
||||
|
||||
@@ -20,6 +20,16 @@ import {
|
||||
useAgents,
|
||||
} from "./store";
|
||||
|
||||
/** The Agents tool window's options menu: where conversations open. */
|
||||
export function useAgentsHeaderMenu(): MenuItem[] {
|
||||
const location = useAgents((s) => s.sessionLocation);
|
||||
return [
|
||||
// A radio pair: check marks only, no action icons beside them.
|
||||
{ kind: "action", action: "agents.useToolWindow", icon: "", checked: location === "toolWindow" },
|
||||
{ kind: "action", action: "agents.useEditor", icon: "", checked: location === "editor" },
|
||||
];
|
||||
}
|
||||
|
||||
const canArchive = (task: AgentTask) => !isBusy(task.status) && task.status !== "archived";
|
||||
const canFork = (task: AgentTask) => canArchive(task) && !!task.capabilities?.fork;
|
||||
|
||||
@@ -152,13 +162,13 @@ export const taskActions: Action[] = [
|
||||
},
|
||||
{
|
||||
id: "agents.forkTask",
|
||||
title: "Copy Agent Task to a New Task",
|
||||
title: "Fork Agent Task",
|
||||
category: "Agents",
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
const task = resolveTasks([arg])[0];
|
||||
if (task && canFork(task))
|
||||
return forkTask(task.id, null).catch((e: unknown) => showError(tx("Couldn't copy the task"), String(e)));
|
||||
return forkTask(task.id, null).catch((e: unknown) => showError(tx("Couldn't fork the task"), String(e)));
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -200,15 +210,14 @@ export function taskMenu(tasks: AgentTask[]): MenuItem[] {
|
||||
return [
|
||||
...(!multiple
|
||||
? ([
|
||||
{ kind: "action", action: "agents.openTask", label: "Open", arg: task.id },
|
||||
{ kind: "action", action: "agents.renameTask", label: "Rename…", icon: "edit", arg: task.id },
|
||||
{ kind: "action", action: "terminal.openAt", label: "Open in Terminal", icon: "terminal", arg: task.cwd },
|
||||
{ kind: "action", action: "terminal.openAt", label: "Terminal", icon: "terminal", arg: task.cwd },
|
||||
...(task.capabilities?.fork && task.status !== "archived"
|
||||
? ([
|
||||
{
|
||||
kind: "action",
|
||||
action: "agents.forkTask",
|
||||
label: "Copy to a New Task",
|
||||
label: "Fork",
|
||||
icon: "branch",
|
||||
arg: task.id,
|
||||
disabled: !canFork(task),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentEntry } from "@/bindings";
|
||||
import { currentTool, foldedTurns, summarizeWork, workParts } from "./timeline";
|
||||
|
||||
const user = (text: string, extra: Partial<Extract<AgentEntry, { type: "user" }>> = {}): AgentEntry => ({
|
||||
type: "user",
|
||||
text,
|
||||
attachments: [],
|
||||
sentMs: 1000,
|
||||
doneMs: 5000,
|
||||
...extra,
|
||||
});
|
||||
const agent = (text: string): AgentEntry => ({ type: "agent", text });
|
||||
const tool = (kind: string, extra: Partial<Extract<AgentEntry, { type: "tool" }>> = {}): AgentEntry => ({
|
||||
type: "tool",
|
||||
id: kind,
|
||||
title: kind,
|
||||
kind,
|
||||
status: "completed",
|
||||
locations: [],
|
||||
output: null,
|
||||
diffs: [],
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("summarizeWork", () => {
|
||||
it("counts tool calls by what they did and edits by file", () => {
|
||||
const s = summarizeWork([
|
||||
{ type: "thought", text: "hmm" },
|
||||
tool("read"),
|
||||
tool("read"),
|
||||
tool("search"),
|
||||
tool("edit", { diffs: [{ path: "/a.ts", added: 3, removed: 1 }] }),
|
||||
tool("edit", { diffs: [{ path: "/a.ts", added: 2, removed: 0 }] }),
|
||||
tool("edit", { locations: ["/b.ts"] }),
|
||||
tool("execute"),
|
||||
tool("mcp_custom"),
|
||||
]);
|
||||
expect(s).toMatchObject({ thoughts: 1, read: 2, search: 1, edited: 2, run: 1, other: 1, added: 5, removed: 1 });
|
||||
expect(workParts(s)).toEqual([
|
||||
{ text: "Edited {count} files", count: 2 },
|
||||
{ text: "Ran 1 command", count: 1 },
|
||||
{ text: "Read {count} files", count: 2 },
|
||||
{ text: "Searched once", count: 1 },
|
||||
{ text: "1 other tool call", count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds the call in progress", () => {
|
||||
expect(currentTool([tool("read", { status: "running" }), tool("search")])?.kind).toBe("read");
|
||||
expect(currentTool([tool("read")])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("foldedTurns", () => {
|
||||
it("folds a finished turn's work before its reply", () => {
|
||||
const entries = [user("q"), tool("read"), agent("mid"), tool("edit"), agent("done"), user("next"), agent("ok")];
|
||||
expect([...foldedTurns(entries, 0, false).values()]).toEqual([{ user: 0, reply: 4, sentMs: 1000, doneMs: 5000 }]);
|
||||
});
|
||||
|
||||
it("leaves the turn in progress, turns without work, and turns ending otherwise open", () => {
|
||||
const working = [user("q"), tool("read"), agent("a")];
|
||||
expect(foldedTurns(working, 0, true).size).toBe(0);
|
||||
expect(foldedTurns(working, 0, false).size).toBe(1);
|
||||
expect(foldedTurns([user("q"), agent("a")], 0, false).size).toBe(0);
|
||||
expect(foldedTurns([user("q"), tool("read")], 0, false).size).toBe(0);
|
||||
expect(foldedTurns([user("q"), tool("read"), agent("a"), { type: "error", message: "x" }], 0, false).size).toBe(0);
|
||||
expect(foldedTurns([user("q"), { type: "error", message: "x" }, tool("read"), agent("a")], 0, false).size).toBe(0);
|
||||
expect(foldedTurns([user("q"), tool("read"), user("more", { steered: true }), agent("a")], 0, false).size).toBe(0);
|
||||
expect(foldedTurns([user("q"), tool("read"), agent("a")], 1, false).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* What a run of the agent's work amounts to, and which finished turns fold away. Pure:
|
||||
* the conversation renders the rows these describe.
|
||||
*/
|
||||
import type { AgentEntry } from "@/bindings";
|
||||
|
||||
type ToolEntry = Extract<AgentEntry, { type: "tool" }>;
|
||||
|
||||
/** Tool calls of a run counted by what they did; edits count the files they changed. */
|
||||
export interface WorkSummary {
|
||||
thoughts: number;
|
||||
read: number;
|
||||
search: number;
|
||||
edited: number;
|
||||
run: number;
|
||||
fetch: number;
|
||||
other: number;
|
||||
added: number;
|
||||
removed: number;
|
||||
subagents: number;
|
||||
}
|
||||
|
||||
export function summarizeWork(entries: Iterable<AgentEntry>): WorkSummary {
|
||||
const s: WorkSummary = {
|
||||
thoughts: 0,
|
||||
read: 0,
|
||||
search: 0,
|
||||
edited: 0,
|
||||
run: 0,
|
||||
fetch: 0,
|
||||
other: 0,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
subagents: 0,
|
||||
};
|
||||
const edited = new Set<string>();
|
||||
for (const e of entries) {
|
||||
if (e.type === "thought") s.thoughts++;
|
||||
else if (e.type === "subagent") s.subagents++;
|
||||
else if (e.type === "tool") countTool(s, e, edited);
|
||||
}
|
||||
s.edited = edited.size;
|
||||
return s;
|
||||
}
|
||||
|
||||
function countTool(s: WorkSummary, e: ToolEntry, edited: Set<string>) {
|
||||
for (const d of e.diffs) {
|
||||
edited.add(d.path);
|
||||
s.added += d.added;
|
||||
s.removed += d.removed;
|
||||
}
|
||||
switch (e.kind) {
|
||||
case "read":
|
||||
s.read++;
|
||||
break;
|
||||
case "search":
|
||||
s.search++;
|
||||
break;
|
||||
case "execute":
|
||||
s.run++;
|
||||
break;
|
||||
case "fetch":
|
||||
s.fetch++;
|
||||
break;
|
||||
case "edit":
|
||||
case "delete":
|
||||
case "move":
|
||||
if (!e.diffs.length) e.locations.forEach((l) => edited.add(l));
|
||||
break;
|
||||
case "think":
|
||||
break;
|
||||
default:
|
||||
s.other++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of a summary line, most telling first: "Read 4 files", "Edited 2 files"… The
|
||||
* caller translates each with its count.
|
||||
*/
|
||||
export function workParts(s: WorkSummary): Array<{ text: string; count: number }> {
|
||||
const parts: Array<[string, string, number]> = [
|
||||
["Edited 1 file", "Edited {count} files", s.edited],
|
||||
["Ran 1 command", "Ran {count} commands", s.run],
|
||||
["Read 1 file", "Read {count} files", s.read],
|
||||
["Searched once", "Searched {count} times", s.search],
|
||||
["Fetched 1 page", "Fetched {count} pages", s.fetch],
|
||||
["1 subagent", "{count} subagents", s.subagents],
|
||||
["1 other tool call", "{count} other tool calls", s.other],
|
||||
];
|
||||
return parts.filter(([, , n]) => n > 0).map(([one, many, count]) => ({ text: count === 1 ? one : many, count }));
|
||||
}
|
||||
|
||||
/** The running tool call of a run, if any: what the agent is doing right now. */
|
||||
export function currentTool(entries: readonly AgentEntry[]): ToolEntry | null {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const e = entries[i]!;
|
||||
if (e.type === "tool" && (e.status === "running" || e.status === "pending")) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A finished turn: the message that asked it at `user`, its work in `[user + 1, reply)`. */
|
||||
export interface FoldedTurn {
|
||||
user: number;
|
||||
/** Index of the reply that stays in view; the turn's work comes before it. */
|
||||
reply: number;
|
||||
sentMs: number | null;
|
||||
doneMs: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The finished turns whose work before their last reply folds into one line. A turn
|
||||
* folds when it has ended, ended with a reply, and did something before that reply (and
|
||||
* neither the user sent anything into it nor it ran into an error meanwhile); the
|
||||
* latest turn stays open while the agent works. Turns asked before `from` aren't shown
|
||||
* and so don't fold.
|
||||
*/
|
||||
export function foldedTurns(entries: readonly AgentEntry[], from: number, busy: boolean): Map<number, FoldedTurn> {
|
||||
const turns = new Map<number, FoldedTurn>();
|
||||
const starts: number[] = [];
|
||||
entries.forEach((e, i) => {
|
||||
if (e.type === "user" && !e.steered && i >= from) starts.push(i);
|
||||
});
|
||||
starts.forEach((user, k) => {
|
||||
const end = starts[k + 1] ?? entries.length;
|
||||
const last = k === starts.length - 1;
|
||||
if (last && busy) return;
|
||||
const asked = entries[user] as Extract<AgentEntry, { type: "user" }>;
|
||||
let reply = -1;
|
||||
for (let i = end - 1; i > user; i--) {
|
||||
const e = entries[i]!;
|
||||
if (e.type === "agent" && e.text.trim()) {
|
||||
reply = i;
|
||||
break;
|
||||
}
|
||||
// Something after the reply still wants to be seen (an error, an open question).
|
||||
if (e.type === "error" || (e.type === "permission" && e.chosen === null)) return;
|
||||
}
|
||||
if (reply < 0 || reply === user + 1) return;
|
||||
// What the user sent into the turn, and an error it ran into, stay in view.
|
||||
if (entries.slice(user + 1, reply).some((e) => e.type === "user" || e.type === "error")) return;
|
||||
turns.set(user, { user, reply, sentMs: asked.sentMs ?? null, doneMs: asked.doneMs ?? null });
|
||||
});
|
||||
return turns;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { TASK_DRAG } from "./model";
|
||||
import {
|
||||
connect,
|
||||
disconnect,
|
||||
loadEntries,
|
||||
dockTask,
|
||||
markVisible,
|
||||
openHome,
|
||||
@@ -30,6 +31,7 @@ const backend = vi.hoisted(() => ({
|
||||
tabs: [] as OpenEditorTab[],
|
||||
channel: null as { onmessage: (event: AgentEvent) => void } | null,
|
||||
tasks: [] as AgentTask[],
|
||||
entries: null as Promise<AgentEntry[]> | null,
|
||||
notify: vi.fn(),
|
||||
openInput: vi.fn(),
|
||||
closeInput: vi.fn(),
|
||||
@@ -52,6 +54,7 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
},
|
||||
agentProviders: async () => ({ status: "ok", data: [] }),
|
||||
agentChanges: async () => ({ status: "ok", data: [] }),
|
||||
agentEntries: async () => ({ status: "ok", data: await backend.entries }),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/workbench/contributions", async (original) => ({
|
||||
@@ -149,6 +152,14 @@ describe("agent unread activity", () => {
|
||||
expect(backend.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tracks a reply that streams on as appends, but not a thought", () => {
|
||||
reply("one", "");
|
||||
emit({ type: "append", task: "one", subagent: null, index: 0, from: 0, text: "Done" });
|
||||
emit({ type: "entry", task: "two", subagent: null, index: 0, entry: { type: "thought", text: "" } });
|
||||
emit({ type: "append", task: "two", subagent: null, index: 0, from: 0, text: "Hmm" });
|
||||
expect([...useAgents.getState().unread]).toEqual(["one"]);
|
||||
});
|
||||
|
||||
it.each(["ready", "needsInput", "failed", "limited"] as const)("marks background %s transitions unread", (status) => {
|
||||
emit({ type: "task", task: task("one", status) });
|
||||
expect(useAgents.getState().unread.has("one")).toBe(true);
|
||||
@@ -371,3 +382,24 @@ describe("docking a conversation in the tool window", () => {
|
||||
expect(useAgents.getState().sessionLocation).toBe("toolWindow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("streaming while the conversation loads", () => {
|
||||
it("applies what arrived meanwhile on top of the loaded copy", async () => {
|
||||
let resolve!: (entries: AgentEntry[]) => void;
|
||||
backend.entries = new Promise((r) => (resolve = r));
|
||||
const load = loadEntries("one");
|
||||
// Sent before the copy was read, then after.
|
||||
emit({ type: "entry", task: "one", subagent: null, index: 1, entry: { type: "agent", text: "He" } });
|
||||
emit({ type: "append", task: "one", subagent: null, index: 1, from: 2, text: "llo" });
|
||||
emit({ type: "append", task: "one", subagent: null, index: 1, from: 5, text: " there" });
|
||||
const user: AgentEntry = { type: "user", text: "Hi", attachments: [] };
|
||||
await act(async () => {
|
||||
resolve([user, { type: "agent", text: "Hello" }]);
|
||||
await load;
|
||||
});
|
||||
expect(useAgents.getState().entries.get("one")).toEqual([user, { type: "agent", text: "Hello there" }]);
|
||||
emit({ type: "append", task: "one", subagent: null, index: 1, from: 11, text: "!" });
|
||||
expect(useAgents.getState().entries.get("one")?.[1]).toEqual({ type: "agent", text: "Hello there!" });
|
||||
expect(useAgents.getState().entryLoads.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -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: {} },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,7 @@ const GROUP_DESCRIPTIONS: Record<string, string> = {
|
||||
Editor: "Configure code editing: completion, folding, tabs, fonts, colors, code style and inspections.",
|
||||
"Build, Execution, Deployment": "Configure build tools, run configurations, debugger and deployment.",
|
||||
"Languages & Frameworks": "Configure language servers, interpreters, SDKs and framework support.",
|
||||
Agents: "Install coding agents and configure how they launch, chat and connect to the IDE.",
|
||||
Tools: "Configure external tools, terminal, database and other integrations.",
|
||||
"Version Control": "Configure the settings related to version control used in your project",
|
||||
};
|
||||
|
||||
@@ -178,7 +178,20 @@ export const DEFAULT_SETTINGS: ValidSettings = {
|
||||
spelling: { ideWords: [], projectWords: [], customDictionaries: [] },
|
||||
tools: {},
|
||||
languageServers: {},
|
||||
agents: { showContextUsage: true, showWorkTime: true, showUsageLimits: true, showCost: false },
|
||||
agents: {
|
||||
showContextUsage: true,
|
||||
showWorkTime: true,
|
||||
showUsageLimits: true,
|
||||
showCost: false,
|
||||
foldTurns: true,
|
||||
smoothText: true,
|
||||
thinking: "auto",
|
||||
toolCalls: "grouped",
|
||||
expandToolOutput: false,
|
||||
sendWith: "enter",
|
||||
whileRunning: "queue",
|
||||
defaults: {},
|
||||
},
|
||||
mcpServer: { enabled: false },
|
||||
...DEFAULT_WORKFLOW,
|
||||
};
|
||||
@@ -199,6 +212,16 @@ type Obj = Record<string, unknown>;
|
||||
const obj = (v: unknown): Obj => (v && typeof v === "object" && !Array.isArray(v) ? (v as Obj) : {});
|
||||
const bool = (v: unknown, d: boolean) => (typeof v === "boolean" ? v : d);
|
||||
const str = (v: unknown, d: string) => (typeof v === "string" && v.trim() ? v : d);
|
||||
/** Agent id → option id → value, keeping only string values. */
|
||||
function agentDefaults(v: unknown): Record<string, Record<string, string>> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj(v)).map(([agent, options]) => [
|
||||
agent,
|
||||
Object.fromEntries(Object.entries(obj(options)).filter((e): e is [string, string] => typeof e[1] === "string")),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const oneOf = <T extends string>(v: unknown, allowed: readonly T[], d: T): T =>
|
||||
typeof v === "string" && (allowed as readonly string[]).includes(v) ? (v as T) : d;
|
||||
const num = (v: unknown, d: number, { min, max }: { min: number; max: number }, int = false) => {
|
||||
@@ -300,6 +323,14 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
|
||||
showWorkTime: bool(agents.showWorkTime, d.agents.showWorkTime),
|
||||
showUsageLimits: bool(agents.showUsageLimits, d.agents.showUsageLimits),
|
||||
showCost: bool(agents.showCost, d.agents.showCost),
|
||||
foldTurns: bool(agents.foldTurns, d.agents.foldTurns),
|
||||
smoothText: bool(agents.smoothText, d.agents.smoothText),
|
||||
thinking: oneOf(agents.thinking, ["auto", "expanded", "collapsed"] as const, d.agents.thinking),
|
||||
toolCalls: oneOf(agents.toolCalls, ["grouped", "each"] as const, d.agents.toolCalls),
|
||||
expandToolOutput: bool(agents.expandToolOutput, d.agents.expandToolOutput),
|
||||
sendWith: oneOf(agents.sendWith, ["enter", "modEnter"] as const, d.agents.sendWith),
|
||||
whileRunning: oneOf(agents.whileRunning, ["queue", "steer"] as const, d.agents.whileRunning),
|
||||
defaults: agentDefaults(agents.defaults),
|
||||
},
|
||||
mcpServer: { enabled: bool(obj(root.mcpServer).enabled, d.mcpServer.enabled) },
|
||||
...validateWorkflow(root),
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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": "에이전트 설정",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 对话"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Заблокувати користувача