feat(agents): Dispatch and supervise coding agents over ACP (#72)
## What An Agents feature: hand a task to a coding agent and follow it from the IDE. Agents speak the [Agent Client Protocol](https://agentclientprotocol.com). Built in: Claude Code, Codex, Gemini CLI, GitHub Copilot, Cursor, OpenCode, Qwen Code, Kimi CLI, Goose, Auggie, Kiro, Mistral Vibe. You can add your own in `<config>/agents/<id>.toml` (`name`, `command`, `args`, `[env]`). An agent whose command isn't on `PATH` is listed under "Not installed". - **New Task page.** An editor page with a prompt box. You pick the agent, model, mode and thinking effort, and whether it runs in a new worktree (from a chosen branch) or in the checkout. - **Agents tool window.** Tasks are grouped by what they need: Needs Input, Failed, Running, Ready to Review, Archived. It also shows a stripe badge, a status bar item and notifications. - **Session tab.** - The conversation: Markdown, thinking, tool calls with diff stats, the plan. - Permission requests appear as an editor banner. - The changed files are compared against the task's base commit and open in the diff viewer. - A context-usage meter. - **Composer.** - `@` references files from the project index. A referenced file's text is sent when the agent accepts embedded context, otherwise a `resource_link`. - `/` lists the agent's commands; ↑/↓ recall earlier messages. - Images can be pasted, dropped or picked; Shift+Tab cycles the mode. - While the agent works, Enter queues the message and ⌘↵ interrupts the turn and sends it now. Queued messages can be sent now, edited or removed. - **Send to Agent.** In the editor and Project view context menus. It adds `@file:10-20` references to the message you typed last. - Landing the work is left to the existing Git features: the worktree is a normal branch. ## How - **`crates/ide-agent`**: one ACP agent process per task, running in the task's directory. - It folds `session/update` into a timeline and answers `session/request_permission`. - It tracks the session's settings: `configOptions`, falling back to the older `modes` / `models` fields. The user's choices are applied again on every new or reloaded session. - Tasks are saved as JSON per task. Sessions resume with `session/load` without duplicating history, and old task files are upgraded when read. - **`ide-jsonrpc`**: adds newline-delimited framing (ACP) next to Content-Length (LSP). - **`ide-vcs`**: adds `numstat_with_worktree` for the changes list. - **Editor**: - The Switcher (⌃⇥) lists every open editor, as IDEA does, not just files. - `EditorKindContribution.restorable` lets a kind's tabs be saved with the editor session, so agent tabs survive reloads. - **UI**: `Select` popups now size to fit short option details (they used to cut them off). ## Testing - `cargo test -p ide-agent` (14 tests). They use an in-memory ACP agent and cover permissions, restart with `session/load`, settings, the queue with send-now and stop, and prompt blocks. - There is also an ignored end-to-end test against the real Claude Code adapter: `OPENIDEA_AGENT_E2E=1 cargo test -p ide-agent real_claude -- --ignored`. - `pnpm vitest run`: 2052 tests. - `cargo clippy -p ide-agent -p openidea --all-targets -- -D warnings` and typecheck are clean. - Driven in the running app with Claude Code: - A task in a worktree, with permission answers, changes listed and the diff opened. - A follow-up after restarting the IDE. - `@` reference with embedded content, `/` commands, Shift+Tab mode changes. - A queued message and send-now, an image prompt, Send to Agent from a selection. - The Switcher and restored tabs after a reload. Not covered yet: only Claude Code was driven end to end. The other agents go through the same ACP path but weren't run here. Merged on the maintainer's go-ahead. Merge with main: kept both sides in Cargo.toml (ide-agent + ide-clipboard), the feature registry (agents + vim) and zh.json (no duplicate keys). After the merge: typecheck clean, full vitest 268 files / 2247 tests passed, ide-agent/ide-jsonrpc/ide-vcs 144 passed, bindings unchanged after regeneration.
@@ -3960,6 +3960,24 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-vcs",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.15"
|
||||
@@ -5862,6 +5880,7 @@ version = "0.1.15"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"ide-agent",
|
||||
"ide-analysis",
|
||||
"ide-build",
|
||||
"ide-clipboard",
|
||||
|
||||
@@ -34,6 +34,7 @@ ide-http = { path = "crates/ide-http" }
|
||||
ide-db = { path = "crates/ide-db" }
|
||||
ide-web = { path = "crates/ide-web" }
|
||||
ide-spell = { path = "crates/ide-spell" }
|
||||
ide-agent = { path = "crates/ide-agent" }
|
||||
ide-clipboard = { path = "crates/ide-clipboard" }
|
||||
|
||||
# serialization & IPC typing
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "ide-agent"
|
||||
description = "Coding agents over the Agent Client Protocol: tasks, sessions, worktrees and their timelines"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-vcs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Session settings (model, mode, thinking effort…), slash commands and context use, as
|
||||
//! the agent reports them over ACP.
|
||||
|
||||
use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
|
||||
/// plus the older `modes` / `models` fields for what those don't cover.
|
||||
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
|
||||
let mut out = result.get("configOptions").map(parse_options).unwrap_or_default();
|
||||
let has = |out: &[AgentConfigOption], category: &str| out.iter().any(|o| o.category.as_deref() == Some(category));
|
||||
if !has(&out, "mode")
|
||||
&& let Some(modes) = result.get("modes")
|
||||
{
|
||||
out.push(legacy(modes, "mode", "Mode", "availableModes", "id", "currentModeId", AgentConfigVia::Mode));
|
||||
}
|
||||
if !has(&out, "model")
|
||||
&& let Some(models) = result.get("models")
|
||||
{
|
||||
out.push(legacy(
|
||||
models,
|
||||
"model",
|
||||
"Model",
|
||||
"availableModels",
|
||||
"modelId",
|
||||
"currentModelId",
|
||||
AgentConfigVia::Model,
|
||||
));
|
||||
}
|
||||
out.retain(|o| !o.values.is_empty());
|
||||
out
|
||||
}
|
||||
|
||||
/// A `configOptions` array; only select options are kept (the UI shows them as dropdowns).
|
||||
pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
|
||||
let Some(list) = options.as_array() else { return Vec::new() };
|
||||
list.iter()
|
||||
.filter(|o| o.get("type").and_then(Value::as_str).is_none_or(|t| t == "select"))
|
||||
.filter_map(|o| {
|
||||
let id = o.get("id").or_else(|| o.get("configId")).and_then(Value::as_str)?.to_owned();
|
||||
let mut values = Vec::new();
|
||||
for v in o.get("options").and_then(Value::as_array).into_iter().flatten() {
|
||||
// Grouped options: `{ group, name, options: [...] }`.
|
||||
match v.get("options").and_then(Value::as_array) {
|
||||
Some(group) => values.extend(group.iter().filter_map(value_of)),
|
||||
None => values.extend(value_of(v)),
|
||||
}
|
||||
}
|
||||
Some(AgentConfigOption {
|
||||
name: str_of(o, "name").unwrap_or_else(|| id.clone()),
|
||||
description: str_of(o, "description"),
|
||||
category: str_of(o, "category").or_else(|| matches!(id.as_str(), "model" | "mode").then(|| id.clone())),
|
||||
current: str_of(o, "currentValue"),
|
||||
values,
|
||||
via: AgentConfigVia::Option,
|
||||
id,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A `config_option_update`: its options replace the config-option ones; `modes` / `models`
|
||||
/// stand-ins stay.
|
||||
pub(crate) fn merge_update(config: &mut Vec<AgentConfigOption>, options: &Value) {
|
||||
let fresh = parse_options(options);
|
||||
let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category);
|
||||
config.retain(|o| o.via != AgentConfigVia::Option && !covered(o));
|
||||
let legacy = std::mem::take(config);
|
||||
config.extend(fresh);
|
||||
config.extend(legacy);
|
||||
config.retain(|o| !o.values.is_empty());
|
||||
}
|
||||
|
||||
/// `current_mode_update`.
|
||||
pub(crate) fn set_mode(config: &mut [AgentConfigOption], mode: &str) {
|
||||
if let Some(o) = config.iter_mut().find(|o| o.category.as_deref() == Some("mode")) {
|
||||
o.current = Some(mode.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
/// The request (method, params without the session id) that sets `option` to `value`.
|
||||
pub(crate) fn set_request(option: &AgentConfigOption, value: &str) -> (&'static str, Value) {
|
||||
match option.via {
|
||||
AgentConfigVia::Option => ("session/set_config_option", json!({ "configId": option.id, "value": value })),
|
||||
AgentConfigVia::Mode => ("session/set_mode", json!({ "modeId": value })),
|
||||
AgentConfigVia::Model => ("session/set_model", json!({ "modelId": value })),
|
||||
}
|
||||
}
|
||||
|
||||
/// `available_commands_update`.
|
||||
pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
|
||||
update
|
||||
.get("availableCommands")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|c| {
|
||||
Some(AgentCommand {
|
||||
name: c.get("name")?.as_str()?.trim_start_matches('/').to_owned(),
|
||||
description: str_of(c, "description").unwrap_or_default(),
|
||||
hint: c.pointer("/input/hint").and_then(Value::as_str).filter(|h| !h.is_empty()).map(str::to_owned),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `usage_update`: `{ used, size, cost?: { amount, currency } }`.
|
||||
pub(crate) fn usage_of(update: &Value) -> Option<AgentUsage> {
|
||||
let tokens = |key: &str| update.get(key)?.as_f64().map(|n| n.clamp(0.0, f64::from(u32::MAX)) as u32);
|
||||
let used = tokens("used")?;
|
||||
let size = tokens("size").filter(|s| *s > 0)?;
|
||||
Some(AgentUsage {
|
||||
used,
|
||||
size,
|
||||
cost: update.pointer("/cost/amount").and_then(Value::as_f64),
|
||||
currency: update.pointer("/cost/currency").and_then(Value::as_str).map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
fn legacy(
|
||||
v: &Value,
|
||||
id: &str,
|
||||
name: &str,
|
||||
list: &str,
|
||||
key: &str,
|
||||
current: &str,
|
||||
via: AgentConfigVia,
|
||||
) -> AgentConfigOption {
|
||||
let values = v
|
||||
.get(list)
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|m| {
|
||||
let value = m.get(key)?.as_str()?.to_owned();
|
||||
Some(AgentConfigValue {
|
||||
name: str_of(m, "name").unwrap_or_else(|| value.clone()),
|
||||
description: str_of(m, "description"),
|
||||
value,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
AgentConfigOption {
|
||||
id: id.to_owned(),
|
||||
name: name.to_owned(),
|
||||
description: None,
|
||||
category: Some(id.to_owned()),
|
||||
current: str_of(v, current),
|
||||
values,
|
||||
via,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_of(v: &Value) -> Option<AgentConfigValue> {
|
||||
let value = v.get("value")?.as_str()?.to_owned();
|
||||
Some(AgentConfigValue {
|
||||
name: str_of(v, "name").unwrap_or_else(|| value.clone()),
|
||||
description: str_of(v, "description"),
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
fn str_of(v: &Value, key: &str) -> Option<String> {
|
||||
v.get(key).and_then(Value::as_str).map(str::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ids(c: &[AgentConfigOption]) -> Vec<(&str, Option<&str>)> {
|
||||
c.iter().map(|o| (o.id.as_str(), o.current.as_deref())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_options_win_over_the_older_fields() {
|
||||
let r = json!({
|
||||
"modes": {"currentModeId": "default", "availableModes": [{"id": "default", "name": "Manual"}]},
|
||||
"configOptions": [
|
||||
{"id": "mode", "category": "mode", "type": "select", "currentValue": "plan",
|
||||
"options": [{"value": "default", "name": "Manual"}, {"value": "plan", "name": "Plan"}]},
|
||||
{"id": "effort", "category": "thought_level", "type": "select", "currentValue": "medium",
|
||||
"options": [{"group": "g", "name": "G", "options": [{"value": "low"}, {"value": "medium", "name": "Medium"}]}]},
|
||||
{"id": "fast", "type": "boolean", "currentValue": false}
|
||||
],
|
||||
"models": {"currentModelId": "m1", "availableModels": [{"modelId": "m1", "name": "One"}]}
|
||||
});
|
||||
let c = options_of(&r);
|
||||
assert_eq!(ids(&c), [("mode", Some("plan")), ("effort", Some("medium")), ("model", Some("m1"))]);
|
||||
assert_eq!(c[1].values.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["low", "Medium"]);
|
||||
assert_eq!(set_request(&c[0], "default").0, "session/set_config_option");
|
||||
assert_eq!(set_request(&c[2], "m1"), ("session/set_model", json!({"modelId": "m1"})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_replace_options_and_track_the_mode() {
|
||||
let mut c = options_of(&json!({
|
||||
"modes": {"currentModeId": "ask", "availableModes": [{"id": "ask"}, {"id": "code"}]},
|
||||
"configOptions": [{"id": "model", "type": "select", "currentValue": "a", "options": [{"value": "a"}]}]
|
||||
}));
|
||||
assert_eq!(c[0].category.as_deref(), Some("model"), "the category defaults from the id");
|
||||
set_mode(&mut c, "code");
|
||||
assert_eq!(c[1].current.as_deref(), Some("code"));
|
||||
assert_eq!(set_request(&c[1], "ask").0, "session/set_mode");
|
||||
merge_update(
|
||||
&mut c,
|
||||
&json!([{"id": "model", "type": "select", "currentValue": "b", "options": [{"value": "b"}]}]),
|
||||
);
|
||||
assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_and_usage() {
|
||||
let cmds = commands_of(&json!({"availableCommands": [
|
||||
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
|
||||
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
|
||||
]}));
|
||||
assert_eq!(cmds[0].hint.as_deref(), Some("what to review"));
|
||||
assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None));
|
||||
let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap();
|
||||
assert_eq!((u.used, u.cost, u.currency.as_deref()), (1200, Some(0.42), Some("USD")));
|
||||
assert!(usage_of(&json!({"used": 1, "size": 0})).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! IPC types: tasks, their timeline entries and the events streamed to the UI.
|
||||
|
||||
use ide_core::IpcPath;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentStatus {
|
||||
/// Creating the worktree / starting the agent.
|
||||
Starting,
|
||||
Running,
|
||||
/// Waiting for a permission answer.
|
||||
NeedsInput,
|
||||
/// The turn ended; the result can be reviewed or the conversation continued.
|
||||
Ready,
|
||||
Failed,
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Isolation {
|
||||
/// A new git worktree on its own branch.
|
||||
Worktree,
|
||||
/// The project's own checkout.
|
||||
Local,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentTask {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
/// [`AgentProvider::id`].
|
||||
pub provider: String,
|
||||
/// Model the task runs on (its `model` setting), when known.
|
||||
pub model: Option<String>,
|
||||
pub status: AgentStatus,
|
||||
pub isolation: Isolation,
|
||||
/// Directory the agent works in (the worktree, or the project root).
|
||||
pub cwd: IpcPath,
|
||||
/// Branch of the task's worktree.
|
||||
pub branch: Option<String>,
|
||||
/// Branch (or revision) the worktree was created from.
|
||||
pub base: Option<String>,
|
||||
/// Commit the task's changes are compared against.
|
||||
pub base_commit: Option<String>,
|
||||
/// Why the task failed, when it did.
|
||||
pub error: Option<String>,
|
||||
pub created_ms: f64,
|
||||
pub updated_ms: f64,
|
||||
/// The session's settings (model, mode, thinking effort…) as last reported, with the
|
||||
/// user's choices applied.
|
||||
pub config: Vec<AgentConfigOption>,
|
||||
/// Slash commands the agent offers.
|
||||
pub commands: Vec<AgentCommand>,
|
||||
/// Context window use, when the agent reports it.
|
||||
pub usage: Option<AgentUsage>,
|
||||
/// Messages waiting for the current turn to end, oldest first.
|
||||
pub queue: Vec<AgentQueued>,
|
||||
}
|
||||
|
||||
/// One value of a select-type session setting.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentConfigValue {
|
||||
pub value: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// How a setting is changed.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentConfigVia {
|
||||
/// `session/set_config_option`.
|
||||
#[default]
|
||||
Option,
|
||||
/// `session/set_mode` (agents that report `modes` instead of a mode config option).
|
||||
Mode,
|
||||
/// `session/set_model` (agents that report `models` instead of a model config option).
|
||||
Model,
|
||||
}
|
||||
|
||||
/// A session setting: an ACP select config option, or the older `modes` / `models`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentConfigOption {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// ACP category: `model`, `mode`, `thought_level`, or agent-specific.
|
||||
pub category: Option<String>,
|
||||
pub current: Option<String>,
|
||||
pub values: Vec<AgentConfigValue>,
|
||||
pub via: AgentConfigVia,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCommand {
|
||||
/// Without the leading `/`.
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// What to type after the command, when it takes input.
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentUsage {
|
||||
/// Tokens in the context window.
|
||||
pub used: u32,
|
||||
/// Size of the context window.
|
||||
pub size: u32,
|
||||
/// Session cost so far.
|
||||
pub cost: Option<f64>,
|
||||
pub currency: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentImage {
|
||||
pub mime_type: String,
|
||||
/// Base64, without a `data:` prefix.
|
||||
pub data: String,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// A message to the agent: text, plus files to look at and images.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentPrompt {
|
||||
pub text: String,
|
||||
pub files: Vec<IpcPath>,
|
||||
pub images: Vec<AgentImage>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentQueued {
|
||||
pub id: String,
|
||||
pub prompt: AgentPrompt,
|
||||
}
|
||||
|
||||
/// A setting chosen for a new task, by [`AgentConfigOption::id`].
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentConfigChoice {
|
||||
pub id: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentToolStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentToolDiff {
|
||||
pub path: IpcPath,
|
||||
pub added: u32,
|
||||
pub removed: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentPlanItem {
|
||||
pub content: String,
|
||||
/// `pending`, `in_progress` or `completed`.
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentPermissionOption {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// `allow_once`, `allow_always`, `reject_once` or `reject_always`.
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// One item of a task's conversation.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum AgentEntry {
|
||||
User {
|
||||
text: String,
|
||||
/// Names of the files and images sent with it.
|
||||
attachments: Vec<String>,
|
||||
},
|
||||
Agent {
|
||||
text: String,
|
||||
},
|
||||
Thought {
|
||||
text: String,
|
||||
},
|
||||
Tool {
|
||||
id: String,
|
||||
title: String,
|
||||
/// ACP tool kind: `read`, `edit`, `execute`, `search`, `fetch`, `think`…
|
||||
kind: String,
|
||||
status: AgentToolStatus,
|
||||
locations: Vec<IpcPath>,
|
||||
/// Text the tool reported (command output…), truncated.
|
||||
output: Option<String>,
|
||||
diffs: Vec<AgentToolDiff>,
|
||||
},
|
||||
Plan {
|
||||
items: Vec<AgentPlanItem>,
|
||||
},
|
||||
Permission {
|
||||
id: u32,
|
||||
title: String,
|
||||
options: Vec<AgentPermissionOption>,
|
||||
/// Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled".
|
||||
chosen: Option<String>,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentProvider {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// The command line that starts its ACP server.
|
||||
pub command: String,
|
||||
/// Whether the command was found (on `PATH`).
|
||||
pub installed: bool,
|
||||
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
|
||||
pub config: Vec<AgentConfigOption>,
|
||||
/// Slash commands it offered last time.
|
||||
pub commands: Vec<AgentCommand>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewAgentTask {
|
||||
pub prompt: AgentPrompt,
|
||||
pub provider: String,
|
||||
/// Settings for the new session (model, mode…).
|
||||
pub config: Vec<AgentConfigChoice>,
|
||||
pub isolation: Isolation,
|
||||
/// Branch to start the worktree from; the current branch when `None`.
|
||||
pub base: Option<String>,
|
||||
}
|
||||
|
||||
/// A file the task changed, relative to [`AgentTask::base_commit`].
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentChange {
|
||||
pub path: IpcPath,
|
||||
pub rel_path: String,
|
||||
pub kind: ide_vcs::ChangeKind,
|
||||
pub added: u32,
|
||||
pub removed: u32,
|
||||
}
|
||||
|
||||
/// Streamed to the window that subscribed (`AgentManager::subscribe`).
|
||||
#[derive(Clone, Debug, Serialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum AgentEvent {
|
||||
/// A task was created or its metadata changed.
|
||||
Task {
|
||||
task: AgentTask,
|
||||
},
|
||||
Removed {
|
||||
id: String,
|
||||
},
|
||||
/// Entry `index` of `task` was added or replaced.
|
||||
Entry {
|
||||
task: String,
|
||||
index: u32,
|
||||
entry: AgentEntry,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Agents and how to launch their ACP servers: the built-in ones, plus user-defined
|
||||
//! agents from `<config>/agents/<id>.toml`.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct ProviderSpec {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub env: Vec<(String, String)>,
|
||||
/// How to sign in when the agent reports it needs authentication.
|
||||
pub login_hint: String,
|
||||
}
|
||||
|
||||
/// `(id, name, command line, sign-in hint)`. npm adapters are pinned so a session's
|
||||
/// protocol does not change under the user.
|
||||
const BUILTIN: &[(&str, &str, &[&str], &str)] = &[
|
||||
(
|
||||
"claude",
|
||||
"Claude Code",
|
||||
&["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.97.0"],
|
||||
"Run `claude` in a terminal and sign in.",
|
||||
),
|
||||
("codex", "Codex", &["npx", "-y", "@zed-industries/codex-acp@0.16.0"], "Run `codex login` in a terminal."),
|
||||
("gemini", "Gemini CLI", &["gemini", "--experimental-acp"], "Run `gemini` in a terminal and sign in."),
|
||||
("copilot", "GitHub Copilot", &["copilot", "--acp"], "Run `copilot` in a terminal and sign in."),
|
||||
("cursor", "Cursor", &["cursor-agent", "acp"], "Run `cursor-agent login` in a terminal."),
|
||||
("opencode", "OpenCode", &["opencode", "acp"], "Run `opencode auth login` in a terminal."),
|
||||
("qwen", "Qwen Code", &["qwen", "--experimental-acp"], "Run `qwen` in a terminal and sign in."),
|
||||
("kimi", "Kimi CLI", &["kimi", "acp"], "Run `kimi` in a terminal and sign in."),
|
||||
("goose", "Goose", &["goose", "acp"], "Run `goose configure` in a terminal."),
|
||||
("auggie", "Auggie", &["auggie", "--acp"], "Run `auggie login` in a terminal."),
|
||||
("kiro", "Kiro", &["kiro-cli", "acp"], "Run `kiro-cli login` in a terminal."),
|
||||
("vibe", "Mistral Vibe", &["vibe-acp"], "Run `vibe` in a terminal and sign in."),
|
||||
];
|
||||
|
||||
fn builtins() -> Vec<ProviderSpec> {
|
||||
BUILTIN
|
||||
.iter()
|
||||
.map(|(id, name, cmd, hint)| ProviderSpec {
|
||||
id: (*id).into(),
|
||||
name: (*name).into(),
|
||||
command: cmd[0].into(),
|
||||
args: cmd[1..].iter().map(|a| (*a).to_owned()).collect(),
|
||||
env: Vec::new(),
|
||||
login_hint: (*hint).into(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CustomAgent {
|
||||
name: Option<String>,
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Built-in agents, then the `*.toml` agents of `custom_dir`; a custom agent with a
|
||||
/// built-in id replaces it.
|
||||
pub(crate) fn load(custom_dir: Option<&Path>) -> Vec<ProviderSpec> {
|
||||
let mut specs = builtins();
|
||||
let mut files: Vec<PathBuf> = custom_dir
|
||||
.and_then(|d| std::fs::read_dir(d).ok())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
|
||||
.collect();
|
||||
files.sort();
|
||||
for path in files {
|
||||
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
|
||||
let parsed = std::fs::read_to_string(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|t| toml::from_str::<CustomAgent>(&t).map_err(|e| e.to_string()));
|
||||
let agent = match parsed {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::warn!("skipping agent {}: {e}", path.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut env: Vec<(String, String)> = agent.env.into_iter().collect();
|
||||
env.sort();
|
||||
let spec = ProviderSpec {
|
||||
name: agent.name.unwrap_or_else(|| id.clone()),
|
||||
command: agent.command,
|
||||
args: agent.args,
|
||||
env,
|
||||
login_hint: "Sign in with the agent's own command line first.".into(),
|
||||
id,
|
||||
};
|
||||
match specs.iter_mut().find(|s| s.id == spec.id) {
|
||||
Some(existing) => *existing = spec,
|
||||
None => specs.push(spec),
|
||||
}
|
||||
}
|
||||
specs
|
||||
}
|
||||
|
||||
/// Whether `command` resolves to an executable (a path, or a name on `PATH`).
|
||||
pub(crate) fn installed(command: &str) -> bool {
|
||||
let p = Path::new(command);
|
||||
if p.components().count() > 1 {
|
||||
return is_executable(p);
|
||||
}
|
||||
let exts: &[&str] = if cfg!(windows) { &["exe", "cmd", "bat"] } else { &[] };
|
||||
std::env::var_os("PATH").is_some_and(|path| {
|
||||
std::env::split_paths(&path).any(|dir| {
|
||||
let c = dir.join(command);
|
||||
is_executable(&c) || exts.iter().any(|e| is_executable(&c.with_extension(e)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
p.metadata().is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
p.is_file()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn custom_agents_extend_and_override_the_builtins() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let write = |name: &str, text: &str| std::fs::write(dir.path().join(name), text).unwrap();
|
||||
write("mine.toml", "name = \"Mine\"\ncommand = \"/opt/mine\"\nargs = [\"acp\"]\n[env]\nK = \"v\"\n");
|
||||
write("gemini.toml", "command = \"gemini\"\nargs = [\"--acp\"]\n");
|
||||
write("broken.toml", "nope = 1\n");
|
||||
let specs = load(Some(dir.path()));
|
||||
let mine = specs.iter().find(|s| s.id == "mine").unwrap();
|
||||
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
|
||||
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
|
||||
let gemini = specs.iter().find(|s| s.id == "gemini").unwrap();
|
||||
assert_eq!((gemini.name.as_str(), gemini.args.as_slice()), ("gemini", &["--acp".to_owned()][..]));
|
||||
assert!(!specs.iter().any(|s| s.id == "broken"));
|
||||
assert_eq!(specs.iter().filter(|s| s.id == "claude").count(), 1);
|
||||
assert!(!installed("/definitely/not/here"));
|
||||
assert!(installed("sh"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
//! End-to-end task flow against an in-memory ACP agent.
|
||||
|
||||
use super::*;
|
||||
use std::sync::OnceLock;
|
||||
use tokio::io::duplex;
|
||||
|
||||
/// Says "Hi", edits a file, asks to run a command and reports what it was allowed.
|
||||
#[derive(Default)]
|
||||
struct FakeAgent {
|
||||
peer: OnceLock<Peer>,
|
||||
calls: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeAgent {
|
||||
fn update(&self, session: &str, update: Value) {
|
||||
let peer = self.peer.get().unwrap();
|
||||
peer.notify("session/update", json!({ "sessionId": session, "update": update })).unwrap();
|
||||
}
|
||||
|
||||
fn say(&self, session: &str, text: &str) {
|
||||
self.update(
|
||||
session,
|
||||
json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler for FakeAgent {
|
||||
fn on_notification(&self, method: &str, _: Value) {
|
||||
self.calls.lock().push(method.to_owned());
|
||||
}
|
||||
|
||||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
self.calls.lock().push(method.to_owned());
|
||||
let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned();
|
||||
match method {
|
||||
"initialize" => Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true}})),
|
||||
"session/new" => Ok(json!({"sessionId": "s1",
|
||||
"modes": {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}, {"id": "code", "name": "Code"}]},
|
||||
"configOptions": [{"id": "model", "category": "model", "type": "select", "currentValue": "big",
|
||||
"options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"}]}]})),
|
||||
"session/load" => {
|
||||
self.say(&session, "replayed history");
|
||||
Ok(json!({}))
|
||||
}
|
||||
"session/set_config_option" | "session/set_mode" => {
|
||||
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
|
||||
Ok(json!({}))
|
||||
}
|
||||
"session/prompt" => {
|
||||
let text = params.pointer("/prompt/0/text").and_then(Value::as_str).unwrap_or_default().to_owned();
|
||||
if text == "again" {
|
||||
self.say(&session, "second turn");
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "wait" {
|
||||
// Works until cancelled.
|
||||
while !self.calls.lock().iter().any(|c| c == "session/cancel") {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
return Ok(json!({"stopReason": "cancelled"}));
|
||||
}
|
||||
self.update(
|
||||
&session,
|
||||
json!({"sessionUpdate": "available_commands_update",
|
||||
"availableCommands": [{"name": "review", "description": "Review", "input": {"hint": "focus"}}]}),
|
||||
);
|
||||
self.update(&session, json!({"sessionUpdate": "usage_update", "used": 50, "size": 1000}));
|
||||
self.say(&session, "Hi");
|
||||
self.update(
|
||||
&session,
|
||||
json!({"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "Edit a.rs",
|
||||
"kind": "edit", "status": "completed",
|
||||
"content": [{"type": "diff", "path": "/p/a.rs", "oldText": "a\n", "newText": "b\n"}]}),
|
||||
);
|
||||
let peer = self.peer.get().unwrap().clone();
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
.block_on(peer.request(
|
||||
"session/request_permission",
|
||||
json!({"sessionId": session, "toolCall": {"toolCallId": "t1", "title": "Run cargo test"},
|
||||
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
|
||||
{"optionId": "no", "name": "Reject", "kind": "reject_once"}]}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
|
||||
self.say(&session, &format!("chose {chosen}"));
|
||||
Ok(json!({"stopReason": "end_turn"}))
|
||||
}
|
||||
_ => Err((codes::METHOD_NOT_FOUND, method.to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn manager(dir: &Path, agent: Arc<FakeAgent>) -> AgentManager {
|
||||
let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| {
|
||||
let (a, b) = duplex(1 << 20);
|
||||
let (ar, aw) = tokio::io::split(a);
|
||||
let (br, bw) = tokio::io::split(b);
|
||||
let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines);
|
||||
// Keep the latest connection: a restarted manager connects again.
|
||||
let _ = agent.peer.set(server);
|
||||
Ok(Peer::connect_framed(ar, aw, client, Framing::Lines))
|
||||
});
|
||||
AgentManager::with_connector(
|
||||
AgentPaths {
|
||||
project_root: dir.to_path_buf(),
|
||||
store_dir: dir.join("store"),
|
||||
worktrees_dir: dir.join("worktrees"),
|
||||
models_file: dir.join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
)
|
||||
}
|
||||
|
||||
async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) {
|
||||
for _ in 0..500 {
|
||||
if mgr.task(id).unwrap().state.lock().task.status == status {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("task never reached {status:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let events = Arc::new(Mutex::new(Vec::<AgentEvent>::new()));
|
||||
let sink = events.clone();
|
||||
mgr.subscribe(Arc::new(move |e| sink.lock().push(e)));
|
||||
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
prompt: text("Fix the lexer\nmore detail"),
|
||||
provider: "claude".into(),
|
||||
config: vec![AgentConfigChoice { id: "model".into(), value: "small".into() }],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(task.title, "Fix the lexer");
|
||||
assert_ne!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready, "a new task is not settled");
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
|
||||
let permission = mgr
|
||||
.entries(&task.id)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Permission { id, .. } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
mgr.respond(&task.id, permission, Some("yes".into())).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
|
||||
let entries = mgr.entries(&task.id).unwrap();
|
||||
let kinds: Vec<String> = entries
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
AgentEntry::User { text, .. } | AgentEntry::Agent { text } => text.clone(),
|
||||
AgentEntry::Tool { title, .. } => title.clone(),
|
||||
AgentEntry::Permission { chosen, .. } => format!("permission: {}", chosen.clone().unwrap_or_default()),
|
||||
other => format!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(kinds, ["Fix the lexer\nmore detail", "Hi", "Edit a.rs", "permission: Allow", "chose yes"]);
|
||||
assert!(
|
||||
agent.calls.lock().contains(&"session/set_config_option \"small\"".to_owned()),
|
||||
"switched to the chosen model"
|
||||
);
|
||||
{
|
||||
let t = mgr.task(&task.id).unwrap().state.lock().task.clone();
|
||||
assert_eq!(t.model.as_deref(), Some("small"));
|
||||
let modes = t.config.iter().find(|o| o.id == "mode").unwrap();
|
||||
assert_eq!((modes.via, modes.current.as_deref()), (AgentConfigVia::Mode, Some("ask")));
|
||||
assert_eq!(t.commands.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["review"]);
|
||||
assert_eq!(t.usage.as_ref().map(|u| (u.used, u.size)), Some((50, 1000)));
|
||||
}
|
||||
mgr.set_config(&task.id, "mode", "code".into()).await.unwrap();
|
||||
assert!(agent.calls.lock().contains(&"session/set_mode \"code\"".to_owned()));
|
||||
assert!(mgr.set_config(&task.id, "mode", "nope".into()).await.is_err());
|
||||
|
||||
tokio::time::sleep(FLUSH_DELAY * 3).await;
|
||||
{
|
||||
let events = events.lock();
|
||||
assert!(
|
||||
events.iter().any(|e| matches!(e, AgentEvent::Task { task } if task.status == AgentStatus::NeedsInput))
|
||||
);
|
||||
let last_entry = events.iter().rev().find_map(|e| match e {
|
||||
AgentEvent::Entry { index, entry, .. } => Some((*index, entry.clone())),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(last_entry, Some((4, AgentEntry::Agent { text: "chose yes".into() })));
|
||||
}
|
||||
let providers = mgr.providers();
|
||||
let claude = providers.iter().find(|p| p.id == "claude").unwrap();
|
||||
assert_eq!(claude.config.iter().map(|o| o.id.as_str()).collect::<Vec<_>>(), ["model", "mode"]);
|
||||
assert_eq!(claude.commands.len(), 1);
|
||||
|
||||
// A new manager (IDE restart) finds the task and resumes its session without duplicating history.
|
||||
mgr.shutdown();
|
||||
let agent2 = Arc::new(FakeAgent::default());
|
||||
let mgr2 = manager(tmp.path(), agent2.clone());
|
||||
assert_eq!(mgr2.entries(&task.id).unwrap().len(), 5);
|
||||
assert_eq!(mgr2.providers().iter().find(|p| p.id == "claude").unwrap().config.len(), 2);
|
||||
mgr2.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
wait_for(&mgr2, &task.id, AgentStatus::Ready).await;
|
||||
let entries = mgr2.entries(&task.id).unwrap();
|
||||
assert_eq!(entries.len(), 7);
|
||||
assert_eq!(entries[6], AgentEntry::Agent { text: "second turn".into() });
|
||||
assert!(agent2.calls.lock().contains(&"session/load".to_owned()));
|
||||
// The chosen settings are re-applied to the reloaded session.
|
||||
let calls = agent2.calls.lock().clone();
|
||||
assert!(calls.contains(&"session/set_mode \"code\"".to_owned()), "{calls:?}");
|
||||
}
|
||||
|
||||
fn text(t: &str) -> AgentPrompt {
|
||||
AgentPrompt { text: t.into(), ..AgentPrompt::default() }
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
prompt: text("wait"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Running).await;
|
||||
mgr.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
let queue = mgr.task(&task.id).unwrap().state.lock().task.queue.clone();
|
||||
assert_eq!(queue.iter().map(|q| q.prompt.text.as_str()).collect::<Vec<_>>(), ["again"]);
|
||||
assert!(mgr.prompt(&task.id, text(" "), false).await.is_err());
|
||||
|
||||
mgr.send_queued_now(&task.id, &queue[0].id).unwrap();
|
||||
for _ in 0..500 {
|
||||
if mgr.entries(&task.id).unwrap().iter().any(|e| *e == AgentEntry::Agent { text: "second turn".into() }) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let users: Vec<String> = mgr
|
||||
.entries(&task.id)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter_map(|e| match e {
|
||||
AgentEntry::User { text, .. } => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(users, ["wait", "again"]);
|
||||
assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty());
|
||||
|
||||
// Stopping a turn keeps the queue for later.
|
||||
mgr.prompt(&task.id, text("wait"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Running).await;
|
||||
agent.calls.lock().retain(|c| c != "session/cancel");
|
||||
mgr.prompt(&task.id, text("later"), false).await.unwrap();
|
||||
mgr.cancel(&task.id).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let queued = mgr.task(&task.id).unwrap().state.lock().task.queue.clone();
|
||||
assert_eq!(queued.len(), 1);
|
||||
assert_eq!(mgr.unqueue(&task.id, &queued[0].id).unwrap().prompt.text, "later");
|
||||
assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompts_become_acp_content_blocks() {
|
||||
let p = AgentPrompt {
|
||||
text: "look".into(),
|
||||
files: vec![IpcPath(PathBuf::from("/p/my file.rs"))],
|
||||
images: vec![AgentImage { mime_type: "image/png".into(), data: "AAA".into(), name: None }],
|
||||
};
|
||||
assert_eq!(
|
||||
blocks(&p, &[]),
|
||||
[
|
||||
json!({"type": "text", "text": "look"}),
|
||||
json!({"type": "resource_link", "uri": "file:///p/my%20file.rs", "name": "my file.rs"}),
|
||||
json!({"type": "image", "mimeType": "image/png", "data": "AAA"}),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
blocks(&p, &[Some("fn a() {}".into())])[1],
|
||||
json!({"type": "resource", "resource": {"uri": "file:///p/my%20file.rs", "text": "fn a() {}"}})
|
||||
);
|
||||
let old = upgrade(
|
||||
json!({"task": {"id": "t"}, "entries": [{"type": "user", "text": "hi"}, {"type": "agent", "text": "a"}]}),
|
||||
);
|
||||
assert_eq!(old["task"]["queue"], json!([]));
|
||||
assert_eq!(old["entries"][0]["attachments"], json!([]));
|
||||
assert!(old["entries"][1].get("attachments").is_none());
|
||||
assert_eq!(title_of(&AgentPrompt { files: p.files.clone(), ..AgentPrompt::default() }), "my file.rs");
|
||||
assert!(clean(text(" ")).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
prompt: text("do it"),
|
||||
provider: "codex".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.cancel(&task.id).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let entries = mgr.entries(&task.id).unwrap();
|
||||
assert!(entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Cancelled")));
|
||||
assert_eq!(entries.last(), Some(&AgentEntry::Agent { text: "chose none".into() }));
|
||||
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.model.as_deref(), Some("big"), "the agent's default");
|
||||
|
||||
mgr.delete(&task.id, false).await.unwrap();
|
||||
assert!(mgr.entries(&task.id).is_err());
|
||||
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
|
||||
assert!(
|
||||
mgr.create(NewAgentTask {
|
||||
prompt: text(" "),
|
||||
provider: "codex".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// Drives the real Claude Code adapter (needs `npx`, network and a signed-in `claude`):
|
||||
/// `OPENIDEA_AGENT_E2E=1 cargo test -p ide-agent real_claude -- --ignored --nocapture`.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore]
|
||||
async fn real_claude_task_in_a_worktree() {
|
||||
if std::env::var_os("OPENIDEA_AGENT_E2E").is_none() {
|
||||
return;
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
for args in [&["init", "-q", "-b", "main"][..], &["commit", "-q", "--allow-empty", "-m", "init"]] {
|
||||
let ok = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.current_dir(&repo)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success();
|
||||
assert!(ok);
|
||||
}
|
||||
let mgr = AgentManager::new(
|
||||
AgentPaths {
|
||||
project_root: repo.clone(),
|
||||
store_dir: tmp.path().join("store"),
|
||||
worktrees_dir: tmp.path().join("wt"),
|
||||
models_file: tmp.path().join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
);
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
prompt: text("Create a file named hello.txt containing the single word hi. Do nothing else."),
|
||||
provider: "claude".into(),
|
||||
config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }],
|
||||
isolation: Isolation::Worktree,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(240);
|
||||
loop {
|
||||
assert!(std::time::Instant::now() < deadline, "timed out");
|
||||
let status = mgr.task(&task.id).unwrap().state.lock().task.status;
|
||||
match status {
|
||||
AgentStatus::NeedsInput => {
|
||||
let pending = mgr.entries(&task.id).unwrap().into_iter().rev().find_map(|e| match e {
|
||||
AgentEntry::Permission { id, options, chosen: None, .. } => Some((id, options)),
|
||||
_ => None,
|
||||
});
|
||||
if let Some((id, options)) = pending {
|
||||
let allow = options.iter().find(|o| o.kind == "allow_once").or(options.first()).unwrap();
|
||||
eprintln!("allowing: {}", allow.name);
|
||||
let _ = mgr.respond(&task.id, id, Some(allow.id.clone()));
|
||||
}
|
||||
}
|
||||
AgentStatus::Ready | AgentStatus::Failed => break,
|
||||
_ => {}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
for e in mgr.entries(&task.id).unwrap() {
|
||||
eprintln!("{e:?}");
|
||||
}
|
||||
let t = mgr.task(&task.id).unwrap().state.lock().task.clone();
|
||||
assert_eq!(t.status, AgentStatus::Ready, "{:?}", t.error);
|
||||
assert_eq!(t.model.as_deref(), Some("haiku"));
|
||||
let changes = mgr.changes(&task.id).await.unwrap();
|
||||
assert!(changes.iter().any(|c| c.rel_path == "hello.txt"), "{changes:?}");
|
||||
assert!(!repo.join("hello.txt").exists(), "the project checkout is untouched");
|
||||
assert!(!mgr.providers().iter().find(|p| p.id == "claude").unwrap().config.is_empty());
|
||||
mgr.shutdown();
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Folds ACP `session/update` notifications into a task's entries.
|
||||
|
||||
use crate::model::{AgentEntry, AgentPlanItem, AgentToolDiff, AgentToolStatus};
|
||||
use ide_core::IpcPath;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Tool output kept per call; longer output keeps its tail.
|
||||
const MAX_OUTPUT: usize = 64 * 1024;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Timeline {
|
||||
pub entries: Vec<AgentEntry>,
|
||||
tools: HashMap<String, usize>,
|
||||
plan: Option<usize>,
|
||||
}
|
||||
|
||||
/// What a `session/update` changed in the timeline.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub(crate) struct Applied {
|
||||
/// Indices of the entries added or changed.
|
||||
pub changed: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
pub fn from_entries(entries: Vec<AgentEntry>) -> Self {
|
||||
let mut t = Self { entries, ..Self::default() };
|
||||
for (i, e) in t.entries.iter().enumerate() {
|
||||
match e {
|
||||
AgentEntry::Tool { id, .. } => {
|
||||
t.tools.insert(id.clone(), i);
|
||||
}
|
||||
AgentEntry::Plan { .. } => t.plan = Some(i),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
pub fn has_tool(&self, id: &str) -> bool {
|
||||
self.tools.contains_key(id)
|
||||
}
|
||||
|
||||
pub fn push(&mut self, entry: AgentEntry) -> usize {
|
||||
self.entries.push(entry);
|
||||
self.entries.len() - 1
|
||||
}
|
||||
|
||||
/// Applies one `session/update` `update` object.
|
||||
pub fn apply(&mut self, update: &Value) -> Applied {
|
||||
let mut out = Applied::default();
|
||||
let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or_default();
|
||||
match kind {
|
||||
"agent_message_chunk" | "agent_thought_chunk" => {
|
||||
let Some(text) = content_text(update.get("content")) else { return out };
|
||||
out.changed.push(self.append_text(kind == "agent_thought_chunk", &text));
|
||||
}
|
||||
"tool_call" => {
|
||||
let id = str_field(update, "toolCallId");
|
||||
let entry = AgentEntry::Tool {
|
||||
id: id.clone(),
|
||||
title: str_field(update, "title"),
|
||||
kind: update.get("kind").and_then(Value::as_str).unwrap_or("other").to_owned(),
|
||||
status: tool_status(update.get("status")).unwrap_or(AgentToolStatus::Pending),
|
||||
locations: locations(update.get("locations")),
|
||||
output: None,
|
||||
diffs: Vec::new(),
|
||||
};
|
||||
let index = match self.tools.get(&id) {
|
||||
Some(&i) => {
|
||||
self.entries[i] = entry;
|
||||
i
|
||||
}
|
||||
None => {
|
||||
let i = self.push(entry);
|
||||
self.tools.insert(id, i);
|
||||
i
|
||||
}
|
||||
};
|
||||
self.merge_tool(index, update);
|
||||
out.changed.push(index);
|
||||
}
|
||||
"tool_call_update" => {
|
||||
let Some(&index) = self.tools.get(&str_field(update, "toolCallId")) else { return out };
|
||||
self.merge_tool(index, update);
|
||||
out.changed.push(index);
|
||||
}
|
||||
"plan" => {
|
||||
let items = update
|
||||
.get("entries")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|e| AgentPlanItem { content: str_field(e, "content"), status: str_field(e, "status") })
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let entry = AgentEntry::Plan { items };
|
||||
let index = match self.plan {
|
||||
Some(i) => {
|
||||
self.entries[i] = entry;
|
||||
i
|
||||
}
|
||||
None => self.push(entry),
|
||||
};
|
||||
self.plan = Some(index);
|
||||
out.changed.push(index);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn append_text(&mut self, thought: bool, text: &str) -> usize {
|
||||
match self.entries.last_mut() {
|
||||
Some(AgentEntry::Agent { text: t }) if !thought => t.push_str(text),
|
||||
Some(AgentEntry::Thought { text: t }) if thought => t.push_str(text),
|
||||
_ => {
|
||||
let text = text.to_owned();
|
||||
return self.push(if thought { AgentEntry::Thought { text } } else { AgentEntry::Agent { text } });
|
||||
}
|
||||
}
|
||||
self.entries.len() - 1
|
||||
}
|
||||
|
||||
fn merge_tool(&mut self, index: usize, update: &Value) {
|
||||
let AgentEntry::Tool { title, kind, status, locations: locs, output, diffs, .. } = &mut self.entries[index]
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(t) = update.get("title").and_then(Value::as_str) {
|
||||
*title = t.to_owned();
|
||||
}
|
||||
if let Some(k) = update.get("kind").and_then(Value::as_str) {
|
||||
*kind = k.to_owned();
|
||||
}
|
||||
if let Some(s) = tool_status(update.get("status")) {
|
||||
*status = s;
|
||||
}
|
||||
if update.get("locations").is_some_and(Value::is_array) {
|
||||
*locs = locations(update.get("locations"));
|
||||
}
|
||||
let Some(content) = update.get("content").and_then(Value::as_array) else { return };
|
||||
let mut text = String::new();
|
||||
let mut new_diffs = Vec::new();
|
||||
for item in content {
|
||||
match item.get("type").and_then(Value::as_str) {
|
||||
Some("content") => {
|
||||
if let Some(t) = content_text(item.get("content")) {
|
||||
if !text.is_empty() {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push_str(&t);
|
||||
}
|
||||
}
|
||||
Some("diff") => {
|
||||
let old = item.get("oldText").and_then(Value::as_str).unwrap_or_default();
|
||||
let new = item.get("newText").and_then(Value::as_str).unwrap_or_default();
|
||||
let (added, removed) = line_stats(old, new);
|
||||
new_diffs.push(AgentToolDiff { path: IpcPath(str_field(item, "path").into()), added, removed });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !text.is_empty() {
|
||||
*output = Some(tail(text));
|
||||
}
|
||||
if !new_diffs.is_empty() {
|
||||
*diffs = new_diffs;
|
||||
}
|
||||
}
|
||||
|
||||
/// Settles what an ended turn left open: running tools and pending permissions.
|
||||
pub fn end_turn(&mut self) -> Vec<usize> {
|
||||
let mut changed = Vec::new();
|
||||
for (i, e) in self.entries.iter_mut().enumerate() {
|
||||
match e {
|
||||
AgentEntry::Tool { status, .. }
|
||||
if matches!(status, AgentToolStatus::Pending | AgentToolStatus::Running) =>
|
||||
{
|
||||
*status = AgentToolStatus::Failed;
|
||||
changed.push(i);
|
||||
}
|
||||
AgentEntry::Permission { chosen, .. } if chosen.is_none() => {
|
||||
*chosen = Some("Cancelled".into());
|
||||
changed.push(i);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
fn str_field(v: &Value, key: &str) -> String {
|
||||
v.get(key).and_then(Value::as_str).unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
fn content_text(content: Option<&Value>) -> Option<String> {
|
||||
let c = content?;
|
||||
match c.get("type").and_then(Value::as_str) {
|
||||
Some("text") => c.get("text").and_then(Value::as_str).map(str::to_owned),
|
||||
Some("resource_link") => c.get("uri").and_then(Value::as_str).map(str::to_owned),
|
||||
Some("resource") => c.pointer("/resource/text").and_then(Value::as_str).map(str::to_owned),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_status(v: Option<&Value>) -> Option<AgentToolStatus> {
|
||||
Some(match v?.as_str()? {
|
||||
"pending" => AgentToolStatus::Pending,
|
||||
"in_progress" => AgentToolStatus::Running,
|
||||
"completed" => AgentToolStatus::Completed,
|
||||
"failed" => AgentToolStatus::Failed,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn locations(v: Option<&Value>) -> Vec<IpcPath> {
|
||||
v.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|l| l.get("path")?.as_str()).map(|p| IpcPath(p.into())).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn line_stats(old: &str, new: &str) -> (u32, u32) {
|
||||
ide_vcs::diff_hunks(old, new).iter().fold((0, 0), |(a, r), h| (a + h.lines, r + h.old_lines))
|
||||
}
|
||||
|
||||
fn tail(mut text: String) -> String {
|
||||
if text.len() > MAX_OUTPUT {
|
||||
let mut cut = text.len() - MAX_OUTPUT;
|
||||
while !text.is_char_boundary(cut) {
|
||||
cut += 1;
|
||||
}
|
||||
text.replace_range(..cut, "…");
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn chunks_merge_into_one_message_until_another_entry_comes() {
|
||||
let mut t = Timeline::default();
|
||||
let chunk = |s: &str| json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": s}});
|
||||
assert_eq!(t.apply(&chunk("Hel")).changed, vec![0]);
|
||||
assert_eq!(t.apply(&chunk("lo")).changed, vec![0]);
|
||||
t.apply(&json!({"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "hmm"}}));
|
||||
t.apply(&chunk("again"));
|
||||
assert_eq!(
|
||||
t.entries,
|
||||
vec![
|
||||
AgentEntry::Agent { text: "Hello".into() },
|
||||
AgentEntry::Thought { text: "hmm".into() },
|
||||
AgentEntry::Agent { text: "again".into() },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_calls_update_in_place() {
|
||||
let mut t = Timeline::default();
|
||||
t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c1", "title": "Edit a.rs", "kind": "edit",
|
||||
"status": "pending", "locations": [{"path": "/p/a.rs", "line": 3}]}));
|
||||
let applied = t.apply(&json!({"sessionUpdate": "tool_call_update", "toolCallId": "c1", "status": "completed",
|
||||
"content": [
|
||||
{"type": "diff", "path": "/p/a.rs", "oldText": "a\nb\n", "newText": "a\nB\nc\n"},
|
||||
{"type": "content", "content": {"type": "text", "text": "done"}}
|
||||
]}));
|
||||
assert_eq!(applied.changed, vec![0]);
|
||||
let AgentEntry::Tool { status, diffs, output, locations, .. } = &t.entries[0] else { panic!() };
|
||||
assert_eq!(*status, AgentToolStatus::Completed);
|
||||
assert_eq!(diffs, &vec![AgentToolDiff { path: IpcPath("/p/a.rs".into()), added: 2, removed: 1 }]);
|
||||
assert_eq!(output.as_deref(), Some("done"));
|
||||
assert_eq!(locations, &vec![IpcPath("/p/a.rs".into())]);
|
||||
// Unknown calls are ignored.
|
||||
assert!(t.apply(&json!({"sessionUpdate": "tool_call_update", "toolCallId": "zz"})).changed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_is_one_entry_and_end_turn_settles_open_items() {
|
||||
let mut t = Timeline::default();
|
||||
let plan =
|
||||
|s: &str| json!({"sessionUpdate": "plan", "entries": [{"content": "x", "priority": "high", "status": s}]});
|
||||
t.apply(&plan("pending"));
|
||||
t.push(AgentEntry::Agent { text: "between".into() });
|
||||
assert_eq!(t.apply(&plan("completed")).changed, vec![0]);
|
||||
t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c", "title": "Run", "status": "in_progress"}));
|
||||
t.push(AgentEntry::Permission { id: 1, title: "Run?".into(), options: vec![], chosen: None });
|
||||
assert_eq!(t.end_turn(), vec![2, 3]);
|
||||
let again = Timeline::from_entries(t.entries.clone());
|
||||
assert_eq!(again.plan, Some(0));
|
||||
assert_eq!(again.tools.get("c"), Some(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_output_keeps_its_tail() {
|
||||
let s = tail("é".repeat(MAX_OUTPUT));
|
||||
assert!(s.starts_with('…') && s.len() <= MAX_OUTPUT + 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Task worktrees and the changes made in them.
|
||||
|
||||
use crate::model::AgentChange;
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_vcs::{ChangeKind, Repo};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) struct Created {
|
||||
pub path: PathBuf,
|
||||
pub branch: String,
|
||||
pub base: String,
|
||||
pub base_commit: String,
|
||||
}
|
||||
|
||||
fn repo_at(path: &Path) -> IdeResult<Repo> {
|
||||
Repo::discover(path)?.ok_or_else(|| IdeError::unavailable(format!("{} is not in a git repository", path.display())))
|
||||
}
|
||||
|
||||
/// The current branch (or commit when detached) and its commit.
|
||||
pub(crate) fn head_of(path: &Path) -> IdeResult<(String, String)> {
|
||||
let info = repo_at(path)?.branch_info()?;
|
||||
let commit = info.head_commit.ok_or_else(|| IdeError::unavailable("the repository has no commits yet"))?;
|
||||
Ok((info.head.unwrap_or_else(|| commit.clone()), commit))
|
||||
}
|
||||
|
||||
/// `git worktree add -b agent/<slug> <dir>/<slug> <base>`.
|
||||
pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, base: Option<&str>) -> IdeResult<Created> {
|
||||
let repo = repo_at(project_root)?;
|
||||
let (base, base_commit) = match base.map(str::trim).filter(|b| !b.is_empty()) {
|
||||
Some(b) => {
|
||||
let commit =
|
||||
repo.merge_base(b, b)?.ok_or_else(|| IdeError::not_found(format!("no branch or revision {b}")))?;
|
||||
(b.to_owned(), commit)
|
||||
}
|
||||
None => head_of(project_root)?,
|
||||
};
|
||||
std::fs::create_dir_all(worktrees_dir).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let branch_exists = |s: &str| {
|
||||
let r = format!("refs/heads/agent/{s}");
|
||||
repo.merge_base(&r, &r).ok().flatten().is_some()
|
||||
};
|
||||
let slug = unique_slug(&slugify(title), |s| worktrees_dir.join(s).exists() || branch_exists(s));
|
||||
let path = worktrees_dir.join(&slug);
|
||||
let branch = format!("agent/{slug}");
|
||||
repo.add_worktree(&path, &base_commit, Some(&branch))?;
|
||||
Ok(Created { path, branch, base, base_commit })
|
||||
}
|
||||
|
||||
pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> {
|
||||
repo_at(project_root)?.remove_worktree(path, true)
|
||||
}
|
||||
|
||||
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
|
||||
pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
let repo = repo_at(cwd)?;
|
||||
let root = repo.root().to_path_buf();
|
||||
let stats = repo.numstat_with_worktree(base)?;
|
||||
let stat = |rel: &str| stats.iter().find(|(p, ..)| p == rel).map_or((0, 0), |&(_, a, r)| (a, r));
|
||||
let mut out: Vec<AgentChange> = repo
|
||||
.diff_with_worktree(base)?
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let (added, removed) = stat(&f.path);
|
||||
AgentChange { path: IpcPath(root.join(&f.path)), rel_path: f.path, kind: f.kind, added, removed }
|
||||
})
|
||||
.collect();
|
||||
for s in repo.status()? {
|
||||
if s.unstaged == Some(ChangeKind::Untracked) && !out.iter().any(|c| c.rel_path == s.rel_path) {
|
||||
let added = std::fs::read_to_string(&*s.path).map_or(0, |t| t.lines().count() as u32);
|
||||
out.push(AgentChange { path: s.path, rel_path: s.rel_path, kind: ChangeKind::Added, added, removed: 0 });
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
|
||||
repo_at(cwd)?.file_at(path, Some(rev))
|
||||
}
|
||||
|
||||
/// A short ASCII branch-safe name from the task title.
|
||||
pub(crate) fn slugify(title: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in title.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else if !out.is_empty() && !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
if out.len() >= 32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let out = out.trim_matches('-');
|
||||
if out.is_empty() { "task".to_owned() } else { out.to_owned() }
|
||||
}
|
||||
|
||||
fn unique_slug(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
if !taken(base) {
|
||||
return base.to_owned();
|
||||
}
|
||||
(2..).map(|n| format!("{base}-{n}")).find(|s| !taken(s)).expect("unbounded")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slugs_are_branch_safe() {
|
||||
assert_eq!(slugify("Fix lexer UTF-16 columns!"), "fix-lexer-utf-16-columns");
|
||||
assert_eq!(slugify("修复 lexer 越界"), "lexer");
|
||||
assert_eq!(slugify("修复越界"), "task");
|
||||
assert_eq!(slugify(&"a".repeat(50)).len(), 32);
|
||||
assert_eq!(unique_slug("x", |s| s == "x" || s == "x-2"), "x-3");
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let ok = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success();
|
||||
assert!(ok, "git {args:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_changes_are_listed_against_the_base() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
|
||||
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
|
||||
assert_eq!(created.branch, "agent/edit-a");
|
||||
assert_eq!(created.base, "main");
|
||||
std::fs::write(created.path.join("a.txt"), "one\nTWO\nthree\n").unwrap();
|
||||
std::fs::write(created.path.join("new.txt"), "x\ny\n").unwrap();
|
||||
|
||||
let got = changes(&created.path, &created.base_commit).unwrap();
|
||||
let summary: Vec<_> = got.iter().map(|c| (c.rel_path.as_str(), c.kind, c.added, c.removed)).collect();
|
||||
assert_eq!(summary, [("a.txt", ChangeKind::Modified, 2, 1), ("new.txt", ChangeKind::Added, 2, 0)]);
|
||||
let base_text = file_at(&created.path, &created.path.join("a.txt"), &created.base_commit).unwrap();
|
||||
assert_eq!(base_text.as_deref(), Some("one\ntwo\n"));
|
||||
|
||||
// A second task with the same title gets its own worktree.
|
||||
assert_eq!(create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap().branch, "agent/edit-a-2");
|
||||
remove(&repo, &created.path).unwrap();
|
||||
assert!(!created.path.exists());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Transport shared by the LSP and DAP clients.
|
||||
//!
|
||||
//! * [`framing`]: `Content-Length` header framing over any async byte stream
|
||||
//! (used by both protocols).
|
||||
//! (used by both protocols), and newline-delimited JSON ([`Framing::Lines`],
|
||||
//! the Agent Client Protocol).
|
||||
//! * [`Peer`]: a JSON-RPC 2.0 endpoint with request/response correlation,
|
||||
//! cancellation (`$/cancelRequest`) and handlers for server→client traffic.
|
||||
|
||||
@@ -88,6 +89,34 @@ pub mod framing {
|
||||
writer.write_all(&buf).await?;
|
||||
writer.flush().await
|
||||
}
|
||||
|
||||
/// Reads one newline-terminated message, skipping blank lines.
|
||||
pub async fn read_line_message<R: AsyncBufRead + Unpin>(reader: &mut R) -> std::io::Result<Option<Vec<u8>>> {
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
line.clear();
|
||||
let n = (&mut *reader).take(MAX_MESSAGE_LEN as u64 + 1).read_until(b'\n', &mut line).await?;
|
||||
if n == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
if line.len() > MAX_MESSAGE_LEN {
|
||||
return Err(Error::new(ErrorKind::InvalidData, "message too large"));
|
||||
}
|
||||
let text = trim_line(&line);
|
||||
if !text.iter().all(u8::is_ascii_whitespace) {
|
||||
return Ok(Some(text.to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes one message followed by `\n` and flushes once.
|
||||
pub async fn write_line_message<W: AsyncWrite + Unpin>(writer: &mut W, body: &[u8]) -> std::io::Result<()> {
|
||||
let mut buf = Vec::with_capacity(body.len() + 1);
|
||||
buf.extend_from_slice(body);
|
||||
buf.push(b'\n');
|
||||
writer.write_all(&buf).await?;
|
||||
writer.flush().await
|
||||
}
|
||||
}
|
||||
|
||||
use parking_lot::Mutex;
|
||||
@@ -210,6 +239,32 @@ impl Inner {
|
||||
}
|
||||
}
|
||||
|
||||
/// How messages are delimited on the wire.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Framing {
|
||||
/// `Content-Length` headers (LSP, DAP).
|
||||
#[default]
|
||||
ContentLength,
|
||||
/// One JSON message per line (Agent Client Protocol).
|
||||
Lines,
|
||||
}
|
||||
|
||||
impl Framing {
|
||||
async fn read<R: tokio::io::AsyncBufRead + Unpin>(self, reader: &mut R) -> std::io::Result<Option<Vec<u8>>> {
|
||||
match self {
|
||||
Framing::ContentLength => framing::read_message(reader).await,
|
||||
Framing::Lines => framing::read_line_message(reader).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn write<W: AsyncWrite + Unpin>(self, writer: &mut W, body: &[u8]) -> std::io::Result<()> {
|
||||
match self {
|
||||
Framing::ContentLength => framing::write_message(writer, body).await,
|
||||
Framing::Lines => framing::write_line_message(writer, body).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connected JSON-RPC peer. Cloning shares the connection.
|
||||
#[derive(Clone)]
|
||||
pub struct Peer {
|
||||
@@ -238,6 +293,19 @@ impl Peer {
|
||||
env: &[(String, String)],
|
||||
handler: Arc<dyn Handler>,
|
||||
on_stderr: Box<dyn Fn(String) + Send + Sync>,
|
||||
) -> std::io::Result<Self> {
|
||||
Self::spawn_framed(command, args, cwd, env, handler, on_stderr, Framing::ContentLength)
|
||||
}
|
||||
|
||||
/// [`spawn`](Self::spawn) with the given wire framing.
|
||||
pub fn spawn_framed(
|
||||
command: &str,
|
||||
args: &[String],
|
||||
cwd: &std::path::Path,
|
||||
env: &[(String, String)],
|
||||
handler: Arc<dyn Handler>,
|
||||
on_stderr: Box<dyn Fn(String) + Send + Sync>,
|
||||
framing: Framing,
|
||||
) -> std::io::Result<Self> {
|
||||
use std::process::Stdio;
|
||||
let mut cmd = tokio::process::Command::new(command);
|
||||
@@ -255,7 +323,7 @@ impl Peer {
|
||||
let stderr = child.stderr.take();
|
||||
let pid = child.id();
|
||||
|
||||
let peer = Self::start(stdout, stdin, handler, false, pid);
|
||||
let peer = Self::start(stdout, stdin, handler, false, pid, framing);
|
||||
|
||||
if let Some(stderr) = stderr {
|
||||
let name = command.to_owned();
|
||||
@@ -299,10 +367,26 @@ impl Peer {
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
Self::start(reader, writer, handler, true, None)
|
||||
Self::start(reader, writer, handler, true, None, Framing::ContentLength)
|
||||
}
|
||||
|
||||
fn start<R, W>(reader: R, mut writer: W, handler: Arc<dyn Handler>, close_on_eof: bool, pid: Option<u32>) -> Self
|
||||
/// [`connect`](Self::connect) with the given wire framing.
|
||||
pub fn connect_framed<R, W>(reader: R, writer: W, handler: Arc<dyn Handler>, framing: Framing) -> Self
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
Self::start(reader, writer, handler, true, None, framing)
|
||||
}
|
||||
|
||||
fn start<R, W>(
|
||||
reader: R,
|
||||
mut writer: W,
|
||||
handler: Arc<dyn Handler>,
|
||||
close_on_eof: bool,
|
||||
pid: Option<u32>,
|
||||
framing: Framing,
|
||||
) -> Self
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
@@ -330,7 +414,7 @@ impl Peer {
|
||||
_ = kill.cancelled() => None,
|
||||
};
|
||||
let Some(body) = body else { break };
|
||||
if let Err(e) = framing::write_message(&mut writer, &body).await {
|
||||
if let Err(e) = framing.write(&mut writer, &body).await {
|
||||
tracing::debug!("jsonrpc write failed: {e}");
|
||||
if let Some(inner) = weak.upgrade() {
|
||||
inner.close_transport();
|
||||
@@ -369,7 +453,7 @@ impl Peer {
|
||||
let mut reader = BufReader::new(reader);
|
||||
loop {
|
||||
let msg = tokio::select! {
|
||||
msg = framing::read_message(&mut reader) => msg,
|
||||
msg = framing.read(&mut reader) => msg,
|
||||
_ = inner.kill.cancelled() => break,
|
||||
};
|
||||
match msg {
|
||||
|
||||
@@ -120,6 +120,28 @@ async fn request_response_and_errors() {
|
||||
assert!(matches!(err, RpcError::Protocol(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn line_framing_round_trips_and_skips_blank_lines() {
|
||||
let mut buf = Vec::new();
|
||||
framing::write_line_message(&mut buf, br#"{"a":1}"#).await.unwrap();
|
||||
buf.extend_from_slice(b"\r\n\n{\"b\":2}\r\n");
|
||||
let mut reader = tokio::io::BufReader::new(&buf[..]);
|
||||
assert_eq!(framing::read_line_message(&mut reader).await.unwrap().unwrap(), br#"{"a":1}"#);
|
||||
assert_eq!(framing::read_line_message(&mut reader).await.unwrap().unwrap(), br#"{"b":2}"#);
|
||||
assert!(framing::read_line_message(&mut reader).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn line_framed_peers_talk() {
|
||||
let (a_io, b_io) = duplex(64 * 1024);
|
||||
let (ar, aw) = tokio::io::split(a_io);
|
||||
let (br, bw) = tokio::io::split(b_io);
|
||||
let client = Peer::connect_framed(ar, aw, Arc::new(NoopHandler), Framing::Lines);
|
||||
let _server = Peer::connect_framed(br, bw, Arc::new(Recorder::default()), Framing::Lines);
|
||||
let v: Value = client.request("echo", json!({"x": "line\nbreak"}), None).await.unwrap();
|
||||
assert_eq!(v, json!({"x": "line\nbreak"}));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn notifications_arrive_in_order() {
|
||||
let rec = Arc::new(Recorder::default());
|
||||
|
||||
@@ -821,6 +821,11 @@ impl Repo {
|
||||
pub fn diff_with_worktree(&self, rev: &str) -> IdeResult<Vec<ChangedFile>> {
|
||||
ops::diff_with_worktree(self, rev)
|
||||
}
|
||||
/// `(repo-relative path, added lines, deleted lines)` of the tracked files whose
|
||||
/// working-tree content differs from `rev`.
|
||||
pub fn numstat_with_worktree(&self, rev: &str) -> IdeResult<Vec<(String, u32, u32)>> {
|
||||
ops::numstat_with_worktree(self, rev)
|
||||
}
|
||||
|
||||
// ---- patches and shelf ----
|
||||
|
||||
|
||||
@@ -349,6 +349,26 @@ pub(crate) fn diff_with_worktree(r: &Repo, rev: &str) -> IdeResult<Vec<ChangedFi
|
||||
Ok(parse_name_status(&out.stdout_str()))
|
||||
}
|
||||
|
||||
/// Added / deleted line counts of tracked files whose working-tree content differs from `rev`
|
||||
/// (`git diff --numstat`); binary files count 0.
|
||||
pub(crate) fn numstat_with_worktree(r: &Repo, rev: &str) -> IdeResult<Vec<(String, u32, u32)>> {
|
||||
check_commits(&[rev.to_string()])?;
|
||||
let out = run(r.root(), &["diff", "--numstat", "-z", "--no-renames", "--no-ext-diff", rev, "--"], None)?;
|
||||
Ok(parse_numstat(&out.stdout_str()))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_numstat(text: &str) -> Vec<(String, u32, u32)> {
|
||||
text.split('\0')
|
||||
.filter_map(|rec| {
|
||||
let mut parts = rec.splitn(3, '\t');
|
||||
let added = parts.next()?.parse().unwrap_or(0);
|
||||
let deleted = parts.next()?.parse().unwrap_or(0);
|
||||
let path = parts.next().filter(|p| !p.is_empty())?;
|
||||
Some((path.to_owned(), added, deleted))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Files that differ between two revisions (Update Project's "N files updated").
|
||||
pub(crate) fn diff_revisions(r: &Repo, from: &str, to: &str) -> IdeResult<Vec<ChangedFile>> {
|
||||
check_commits(&[from.to_string(), to.to_string()])?;
|
||||
@@ -402,4 +422,13 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numstat_parsing() {
|
||||
let got = parse_numstat("3\t1\tsrc/a.rs\0-\t-\timg.png\0\x30\t7\tdir/b c.txt\0");
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![("src/a.rs".to_owned(), 3, 1), ("img.png".to_owned(), 0, 0), ("dir/b c.txt".to_owned(), 0, 7)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ ide-analysis.workspace = true
|
||||
ide-coverage.workspace = true
|
||||
ide-profiler.workspace = true
|
||||
ide-http.workspace = true
|
||||
ide-agent.workspace = true
|
||||
ide-db.workspace = true
|
||||
ide-web.workspace = true
|
||||
ide-spell.workspace = true
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
use crate::state::AppState;
|
||||
use ide_agent::{
|
||||
AgentChange, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask,
|
||||
};
|
||||
use ide_core::{IdeResult, IpcPath};
|
||||
use std::sync::Arc;
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::{State, Window};
|
||||
|
||||
/// Streams the project's agent events to this window; returns every task.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_subscribe(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
on_event: Channel<AgentEvent>,
|
||||
) -> IdeResult<Vec<AgentTask>> {
|
||||
let project = state.project(window.label())?;
|
||||
Ok(project.agents.subscribe(Arc::new(move |e| {
|
||||
let _ = on_event.send(e);
|
||||
})))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_providers(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<AgentProvider>> {
|
||||
Ok(state.project(window.label())?.agents.providers())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_entries(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<Vec<AgentEntry>> {
|
||||
state.project(window.label())?.agents.entries(&task)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn agent_create(window: Window, state: State<'_, AppState>, request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
let project = state.project(window.label())?;
|
||||
project.agents.create(request).await
|
||||
}
|
||||
|
||||
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn agent_prompt(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
task: String,
|
||||
prompt: AgentPrompt,
|
||||
now: bool,
|
||||
) -> IdeResult<()> {
|
||||
let project = state.project(window.label())?;
|
||||
project.agents.prompt(&task, prompt, now).await
|
||||
}
|
||||
|
||||
/// Drops a queued message and returns it (to edit it).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_unqueue(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
task: String,
|
||||
queued: String,
|
||||
) -> IdeResult<AgentQueued> {
|
||||
state.project(window.label())?.agents.unqueue(&task, &queued)
|
||||
}
|
||||
|
||||
/// Sends a queued message now, interrupting the current turn.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_send_queued_now(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
task: String,
|
||||
queued: String,
|
||||
) -> IdeResult<()> {
|
||||
state.project(window.label())?.agents.send_queued_now(&task, &queued)
|
||||
}
|
||||
|
||||
/// Changes a session setting (model, mode, thinking effort…).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn agent_set_config(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
task: String,
|
||||
option: String,
|
||||
value: String,
|
||||
) -> IdeResult<()> {
|
||||
let project = state.project(window.label())?;
|
||||
project.agents.set_config(&task, &option, value).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_cancel(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> {
|
||||
state.project(window.label())?.agents.cancel(&task)
|
||||
}
|
||||
|
||||
/// Answers a permission request; `option: null` cancels it.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_respond(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
task: String,
|
||||
permission: u32,
|
||||
option: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
state.project(window.label())?.agents.respond(&task, permission, option)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_archive(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> {
|
||||
state.project(window.label())?.agents.archive(&task)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn agent_delete(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
task: String,
|
||||
remove_worktree: bool,
|
||||
) -> IdeResult<()> {
|
||||
let project = state.project(window.label())?;
|
||||
project.agents.delete(&task, remove_worktree).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn agent_changes(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<Vec<AgentChange>> {
|
||||
let project = state.project(window.label())?;
|
||||
project.agents.changes(&task).await
|
||||
}
|
||||
|
||||
/// Content of `path` when the task started (left side of its diff).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn agent_base_text(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
task: String,
|
||||
path: IpcPath,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let project = state.project(window.label())?;
|
||||
project.agents.base_text(&task, path.into_path_buf()).await
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! * streaming results use a `Channel<T>` argument; cancellable requests take a
|
||||
//! UI-allocated `RequestId` and are cancelled via `cancel_request`.
|
||||
|
||||
pub mod agent;
|
||||
pub mod analysis;
|
||||
pub mod app;
|
||||
pub mod build;
|
||||
@@ -59,6 +60,20 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
app::project_roots,
|
||||
app::cancel_request,
|
||||
app::log_frontend,
|
||||
agent::agent_subscribe,
|
||||
agent::agent_providers,
|
||||
agent::agent_entries,
|
||||
agent::agent_create,
|
||||
agent::agent_prompt,
|
||||
agent::agent_unqueue,
|
||||
agent::agent_send_queued_now,
|
||||
agent::agent_set_config,
|
||||
agent::agent_cancel,
|
||||
agent::agent_respond,
|
||||
agent::agent_archive,
|
||||
agent::agent_delete,
|
||||
agent::agent_changes,
|
||||
agent::agent_base_text,
|
||||
fs::fs_read_dir,
|
||||
bytes::fs_probe,
|
||||
bytes::fs_read_range,
|
||||
|
||||
@@ -74,6 +74,8 @@ pub struct Project {
|
||||
pub db: Arc<ide_db::DbManager>,
|
||||
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
|
||||
pub http_history: Arc<ide_http::History>,
|
||||
/// Coding agent tasks (Agents tool window).
|
||||
pub agents: Arc<ide_agent::AgentManager>,
|
||||
watcher: Watcher,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
@@ -403,6 +405,21 @@ impl AppState {
|
||||
self.data_dir.join("db-history.json"),
|
||||
));
|
||||
let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(&project_key)));
|
||||
let agents = Arc::new(ide_agent::AgentManager::new(
|
||||
ide_agent::AgentPaths {
|
||||
project_root: root.clone(),
|
||||
store_dir: self.data_dir.join("agents").join(&project_key),
|
||||
// Outside the app data dir: its path has spaces on macOS, which trips up build scripts.
|
||||
worktrees_dir: dirs::home_dir()
|
||||
.unwrap_or_else(|| self.data_dir.clone())
|
||||
.join(".openidea")
|
||||
.join("worktrees")
|
||||
.join(&project_key),
|
||||
models_file: self.data_dir.join("agents").join("models.json"),
|
||||
custom_agents_dir: Some(self.config_dir.join("agents")),
|
||||
},
|
||||
tauri::async_runtime::handle().inner().clone(),
|
||||
));
|
||||
let project = Arc::new(Project {
|
||||
root,
|
||||
sink,
|
||||
@@ -420,6 +437,7 @@ impl AppState {
|
||||
cache_dir: self.data_dir.join("cache").join(&project_key),
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(content_watchers),
|
||||
_scratch_watcher: scratch_watcher,
|
||||
@@ -430,6 +448,7 @@ impl AppState {
|
||||
|
||||
pub fn close_project(&self, window: &str) {
|
||||
if let Some(project) = self.projects.write().remove(window) {
|
||||
project.agents.shutdown();
|
||||
let lsp = project.lsp.clone();
|
||||
tauri::async_runtime::spawn(async move { lsp.shutdown_all().await });
|
||||
}
|
||||
|
||||
@@ -53,6 +53,27 @@ export const commands = {
|
||||
cancelRequest: (id: RequestId) => __TAURI_INVOKE<void>("cancel_request", { id }),
|
||||
/** Routes frontend logs into the backend `tracing` pipeline. */
|
||||
logFrontend: (level: LogLevel, message: string) => __TAURI_INVOKE<void>("log_frontend", { level, message }),
|
||||
/** Streams the project's agent events to this window; returns every task. */
|
||||
agentSubscribe: (onEvent: Channel<AgentEvent>) => typedError<AgentTask[], IdeError>(__TAURI_INVOKE("agent_subscribe", { onEvent })),
|
||||
agentProviders: () => typedError<AgentProvider[], IdeError>(__TAURI_INVOKE("agent_providers")),
|
||||
agentEntries: (task: string) => typedError<AgentEntry[], IdeError>(__TAURI_INVOKE("agent_entries", { task })),
|
||||
agentCreate: (request: NewAgentTask) => typedError<AgentTask, IdeError>(__TAURI_INVOKE("agent_create", { request })),
|
||||
/** Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. */
|
||||
agentPrompt: (task: string, prompt: AgentPrompt, now: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_prompt", { task, prompt, now })),
|
||||
/** Drops a queued message and returns it (to edit it). */
|
||||
agentUnqueue: (task: string, queued: string) => typedError<AgentQueued, IdeError>(__TAURI_INVOKE("agent_unqueue", { task, queued })),
|
||||
/** Sends a queued message now, interrupting the current turn. */
|
||||
agentSendQueuedNow: (task: string, queued: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_send_queued_now", { task, queued })),
|
||||
/** Changes a session setting (model, mode, thinking effort…). */
|
||||
agentSetConfig: (task: string, option: string, value: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_set_config", { task, option, value })),
|
||||
agentCancel: (task: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_cancel", { task })),
|
||||
/** Answers a permission request; `option: null` cancels it. */
|
||||
agentRespond: (task: string, permission: number, option: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_respond", { task, permission, option })),
|
||||
agentArchive: (task: string) => typedError<null, IdeError>(__TAURI_INVOKE("agent_archive", { task })),
|
||||
agentDelete: (task: string, removeWorktree: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_delete", { task, removeWorktree })),
|
||||
agentChanges: (task: string) => typedError<AgentChange[], IdeError>(__TAURI_INVOKE("agent_changes", { task })),
|
||||
/** Content of `path` when the task started (left side of its diff). */
|
||||
agentBaseText: (task: string, path: IpcPath) => typedError<string | null, IdeError>(__TAURI_INVOKE("agent_base_text", { task, path })),
|
||||
fsReadDir: (path: string) => typedError<FileEntry[], IdeError>(__TAURI_INVOKE("fs_read_dir", { path })),
|
||||
/** Size and text/binary classification, from the file's head only. */
|
||||
fsProbe: (path: string) => typedError<FileProbe, IdeError>(__TAURI_INVOKE("fs_probe", { path })),
|
||||
@@ -816,6 +837,180 @@ export type ActiveTheme = {
|
||||
theme: ThemeDef,
|
||||
};
|
||||
|
||||
/** A file the task changed, relative to [`AgentTask::base_commit`]. */
|
||||
export type AgentChange = {
|
||||
path: IpcPath,
|
||||
relPath: string,
|
||||
kind: ChangeKind,
|
||||
added: number,
|
||||
removed: number,
|
||||
};
|
||||
|
||||
export type AgentCommand = {
|
||||
/** Without the leading `/`. */
|
||||
name: string,
|
||||
description: string,
|
||||
/** What to type after the command, when it takes input. */
|
||||
hint: string | null,
|
||||
};
|
||||
|
||||
/** A setting chosen for a new task, by [`AgentConfigOption::id`]. */
|
||||
export type AgentConfigChoice = {
|
||||
id: string,
|
||||
value: string,
|
||||
};
|
||||
|
||||
/** A session setting: an ACP select config option, or the older `modes` / `models`. */
|
||||
export type AgentConfigOption = {
|
||||
id: string,
|
||||
name: string,
|
||||
description: string | null,
|
||||
/** ACP category: `model`, `mode`, `thought_level`, or agent-specific. */
|
||||
category: string | null,
|
||||
current: string | null,
|
||||
values: AgentConfigValue[],
|
||||
via: AgentConfigVia,
|
||||
};
|
||||
|
||||
/** One value of a select-type session setting. */
|
||||
export type AgentConfigValue = {
|
||||
value: string,
|
||||
name: string,
|
||||
description: string | null,
|
||||
};
|
||||
|
||||
/** How a setting is changed. */
|
||||
export type AgentConfigVia =
|
||||
/** `session/set_config_option`. */
|
||||
"option" |
|
||||
/** `session/set_mode` (agents that report `modes` instead of a mode config option). */
|
||||
"mode" |
|
||||
/** `session/set_model` (agents that report `models` instead of a model config option). */
|
||||
"model";
|
||||
|
||||
/** One item of a task's conversation. */
|
||||
export type AgentEntry = { type: "user"; text: string;
|
||||
/** Names of the files and images sent with it. */
|
||||
attachments: string[] } | { type: "agent"; text: string } | { type: "thought"; text: string } | { type: "tool"; id: string; title: string;
|
||||
/** ACP tool kind: `read`, `edit`, `execute`, `search`, `fetch`, `think`… */
|
||||
kind: string; status: AgentToolStatus; locations: IpcPath[];
|
||||
/** Text the tool reported (command output…), truncated. */
|
||||
output: string | null; diffs: AgentToolDiff[] } | { type: "plan"; items: AgentPlanItem[] } | { type: "permission"; id: number; title: string; options: AgentPermissionOption[];
|
||||
/** Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled". */
|
||||
chosen: string | null } | { type: "error"; message: string };
|
||||
|
||||
/** Streamed to the window that subscribed (`AgentManager::subscribe`). */
|
||||
export type AgentEvent =
|
||||
/** A task was created or its metadata changed. */
|
||||
{ type: "task"; task: AgentTask } | { type: "removed"; id: string } |
|
||||
/** Entry `index` of `task` was added or replaced. */
|
||||
{ type: "entry"; task: string; index: number; entry: AgentEntry };
|
||||
|
||||
export type AgentImage = {
|
||||
mimeType: string,
|
||||
/** Base64, without a `data:` prefix. */
|
||||
data: string,
|
||||
name: string | null,
|
||||
};
|
||||
|
||||
export type AgentPermissionOption = {
|
||||
id: string,
|
||||
name: string,
|
||||
/** `allow_once`, `allow_always`, `reject_once` or `reject_always`. */
|
||||
kind: string,
|
||||
};
|
||||
|
||||
export type AgentPlanItem = {
|
||||
content: string,
|
||||
/** `pending`, `in_progress` or `completed`. */
|
||||
status: string,
|
||||
};
|
||||
|
||||
/** A message to the agent: text, plus files to look at and images. */
|
||||
export type AgentPrompt = {
|
||||
text: string,
|
||||
files: IpcPath[],
|
||||
images: AgentImage[],
|
||||
};
|
||||
|
||||
export type AgentProvider = {
|
||||
id: string,
|
||||
name: string,
|
||||
/** The command line that starts its ACP server. */
|
||||
command: string,
|
||||
/** Whether the command was found (on `PATH`). */
|
||||
installed: boolean,
|
||||
/** Settings (models, modes…) the agent offered last time it ran; empty before its first session. */
|
||||
config: AgentConfigOption[],
|
||||
/** Slash commands it offered last time. */
|
||||
commands: AgentCommand[],
|
||||
};
|
||||
|
||||
export type AgentQueued = {
|
||||
id: string,
|
||||
prompt: AgentPrompt,
|
||||
};
|
||||
|
||||
export type AgentStatus =
|
||||
/** Creating the worktree / starting the agent. */
|
||||
"starting" | "running" |
|
||||
/** Waiting for a permission answer. */
|
||||
"needsInput" |
|
||||
/** The turn ended; the result can be reviewed or the conversation continued. */
|
||||
"ready" | "failed" | "archived";
|
||||
|
||||
export type AgentTask = {
|
||||
id: string,
|
||||
title: string,
|
||||
/** [`AgentProvider::id`]. */
|
||||
provider: string,
|
||||
/** Model the task runs on (its `model` setting), when known. */
|
||||
model: string | null,
|
||||
status: AgentStatus,
|
||||
isolation: Isolation,
|
||||
/** Directory the agent works in (the worktree, or the project root). */
|
||||
cwd: IpcPath,
|
||||
/** Branch of the task's worktree. */
|
||||
branch: string | null,
|
||||
/** Branch (or revision) the worktree was created from. */
|
||||
base: string | null,
|
||||
/** Commit the task's changes are compared against. */
|
||||
baseCommit: string | null,
|
||||
/** Why the task failed, when it did. */
|
||||
error: string | null,
|
||||
createdMs: number | null,
|
||||
updatedMs: number | null,
|
||||
/**
|
||||
* The session's settings (model, mode, thinking effort…) as last reported, with the
|
||||
* user's choices applied.
|
||||
*/
|
||||
config: AgentConfigOption[],
|
||||
/** Slash commands the agent offers. */
|
||||
commands: AgentCommand[],
|
||||
/** Context window use, when the agent reports it. */
|
||||
usage: AgentUsage | null,
|
||||
/** Messages waiting for the current turn to end, oldest first. */
|
||||
queue: AgentQueued[],
|
||||
};
|
||||
|
||||
export type AgentToolDiff = {
|
||||
path: IpcPath,
|
||||
added: number,
|
||||
removed: number,
|
||||
};
|
||||
|
||||
export type AgentToolStatus = "pending" | "running" | "completed" | "failed";
|
||||
|
||||
export type AgentUsage = {
|
||||
/** Tokens in the context window. */
|
||||
used: number,
|
||||
/** Size of the context window. */
|
||||
size: number,
|
||||
/** Session cost so far. */
|
||||
cost: number | null,
|
||||
currency: string | null,
|
||||
};
|
||||
|
||||
export type AppInfo = {
|
||||
version: string,
|
||||
platform: string,
|
||||
@@ -2910,6 +3105,12 @@ export type InvalidateCachesOptions = {
|
||||
/** A path field of an IPC type. */
|
||||
export type IpcPath = string;
|
||||
|
||||
export type Isolation =
|
||||
/** A new git worktree on its own branch. */
|
||||
"worktree" |
|
||||
/** The project's own checkout. */
|
||||
"local";
|
||||
|
||||
export type IssueLink = {
|
||||
/** Regular expression matched in commit messages. */
|
||||
pattern?: string,
|
||||
@@ -3454,6 +3655,16 @@ export type MethodStat = {
|
||||
total: number | null,
|
||||
};
|
||||
|
||||
export type NewAgentTask = {
|
||||
prompt: AgentPrompt,
|
||||
provider: string,
|
||||
/** Settings for the new session (model, mode…). */
|
||||
config: AgentConfigChoice[],
|
||||
isolation: Isolation,
|
||||
/** Branch to start the worktree from; the current branch when `None`. */
|
||||
base: string | null,
|
||||
};
|
||||
|
||||
/** Result of `node_at`: the smallest named node covering a range, with its ancestors. */
|
||||
export type NodeAt = {
|
||||
node: SyntaxNode,
|
||||
|
||||
@@ -21,11 +21,13 @@ import {
|
||||
menuGroupItems,
|
||||
notifications,
|
||||
resolveFileEditor,
|
||||
useContributions,
|
||||
type EditorInput,
|
||||
type OpenOptions,
|
||||
type OpenEditorTab,
|
||||
} from "@/workbench/contributions";
|
||||
import { useDistractionFree } from "@/workbench/distractionFree";
|
||||
import { showContextMenu, type MenuItem } from "@/ui";
|
||||
import { fileIconName, showContextMenu, type MenuItem } from "@/ui";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import {
|
||||
activateTab,
|
||||
@@ -140,6 +142,32 @@ function isTextTab(tab: Tab | null | undefined): tab is Tab & { input: { path: s
|
||||
return !!tab && isTextInput(tab.input);
|
||||
}
|
||||
|
||||
type EditorKinds = ReturnType<typeof useContributions.getState>["editorKinds"];
|
||||
|
||||
/** Tab title of an editor input: the file name, or what its editor kind calls it. */
|
||||
export function tabTitle(input: EditorInput, kinds: EditorKinds): string {
|
||||
if (input.kind === "file") return basename(input.path ?? input.key);
|
||||
if (input.kind === "virtual")
|
||||
return (input.data as VirtualInputData | undefined)?.title ?? basename(input.path ?? input.key);
|
||||
const kind = kinds.get(input.kind);
|
||||
try {
|
||||
return kind?.title(input) ?? input.key;
|
||||
} catch {
|
||||
return input.key;
|
||||
}
|
||||
}
|
||||
|
||||
export function tabIcon(input: EditorInput, kinds: EditorKinds): string {
|
||||
if (input.kind === "file") return fileIconName(basename(input.path ?? ""));
|
||||
if (input.kind === "virtual") return fileIconName(tabTitle(input, kinds));
|
||||
const kind = kinds.get(input.kind);
|
||||
try {
|
||||
return kind?.icon?.(input) ?? "file";
|
||||
} catch {
|
||||
return "file";
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorController implements SlotHost {
|
||||
manager: DocumentManager;
|
||||
readonly slots = new Map<string, EditorSlot>();
|
||||
@@ -157,6 +185,9 @@ export class EditorController implements SlotHost {
|
||||
private restoredViews = new Map<string, FileViewState>();
|
||||
/** Tabs created by session restore; dropped silently if their file is gone. */
|
||||
private restoredTabs = new Set<string>();
|
||||
/** When each input key was last the active tab (a counter, across panes). */
|
||||
private lastActive = new Map<string, number>();
|
||||
private activeClock = 0;
|
||||
|
||||
constructor(backend: DocBackend = docBackend(), scheduler?: Scheduler) {
|
||||
this.manager = new DocumentManager(
|
||||
@@ -178,6 +209,7 @@ export class EditorController implements SlotHost {
|
||||
useTabs.subscribe((s) => {
|
||||
const prev = prevTabs;
|
||||
prevTabs = s;
|
||||
this.stampActive(s);
|
||||
this.onTabsChanged(prev, s);
|
||||
}),
|
||||
);
|
||||
@@ -301,7 +333,8 @@ export class EditorController implements SlotHost {
|
||||
if (files[path] && slot.model.lastFocused !== slot && !this.restoredViews.has(path)) continue;
|
||||
files[path] = slot.viewState();
|
||||
}
|
||||
storeSession(root, serializeSession(useTabs.getState(), files));
|
||||
const kinds = useContributions.getState().editorKinds;
|
||||
storeSession(root, serializeSession(useTabs.getState(), files, (input) => !!kinds.get(input.kind)?.restorable));
|
||||
}
|
||||
|
||||
/** Reopens the tabs, splits and carets saved for `root` (after the project opened). */
|
||||
@@ -993,6 +1026,29 @@ export class EditorController implements SlotHost {
|
||||
pane?.querySelector<HTMLElement>("[data-editor-focus], .cm-content")?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
private stampActive(s: TabsState) {
|
||||
const pane = s.panes[s.activePane];
|
||||
const key = pane?.tabs.find((t) => t.id === pane.active)?.input.key;
|
||||
if (key && this.lastActive.get(key) !== this.activeClock) this.lastActive.set(key, ++this.activeClock);
|
||||
}
|
||||
|
||||
/** Every open tab once (all panes), most recently active first; tabs never active this session follow in tab order. */
|
||||
openTabs(): OpenEditorTab[] {
|
||||
const s = useTabs.getState();
|
||||
const kinds = useContributions.getState().editorKinds;
|
||||
const out = new Map<string, OpenEditorTab>();
|
||||
for (const id of paneIds(s))
|
||||
for (const t of s.panes[id]?.tabs ?? [])
|
||||
if (!out.has(t.input.key))
|
||||
out.set(t.input.key, {
|
||||
input: t.input,
|
||||
title: tabTitle(t.input, kinds),
|
||||
icon: tabIcon(t.input, kinds),
|
||||
lastActive: this.lastActive.get(t.input.key) ?? 0,
|
||||
});
|
||||
return [...out.values()].sort((a, b) => b.lastActive - a.lastActive);
|
||||
}
|
||||
|
||||
openPaths(): string[] {
|
||||
const s = useTabs.getState();
|
||||
const out = new Set<string>();
|
||||
|
||||
@@ -42,6 +42,7 @@ export function activate(): void | (() => void) {
|
||||
saveAll: () => ctrl.saveAll(),
|
||||
focus: () => ctrl.focus(),
|
||||
openPaths: () => ctrl.openPaths(),
|
||||
openTabs: () => ctrl.openTabs(),
|
||||
closePath: (path) => ctrl.closePath(path),
|
||||
closeInput: (key) => ctrl.closeInput(key),
|
||||
reformat: async (path) => {
|
||||
|
||||
@@ -39,6 +39,31 @@ describe("editor session", () => {
|
||||
expect(r.layout).toMatchObject({ kind: "split", dir: "column" });
|
||||
});
|
||||
|
||||
it("saves tabs of restorable editor kinds with their input", () => {
|
||||
let s = initialTabsState();
|
||||
s = open(s, "/p/a.ts");
|
||||
s = openTab(s, s.activePane, { kind: "agent-session", key: "agent:1", data: { task: "1" } }).state;
|
||||
s = openTab(s, s.activePane, { kind: "diff", key: "diff:x", data: { big: true } }).state;
|
||||
s = openTab(s, s.activePane, { kind: "agent-session", key: "agent:1", data: { task: "1" } }).state;
|
||||
const restorable = (i: { kind: string }) => i.kind === "agent-session";
|
||||
const session = serializeSession(s, {}, restorable)!;
|
||||
expect(session.layout).toMatchObject({ kind: "pane", active: 1 });
|
||||
const r = restoreTabsState(parseSession(JSON.parse(JSON.stringify(session)))!, fileKey);
|
||||
const pane = r.panes[paneIds(r)[0]!]!;
|
||||
expect(pane.tabs.map((t) => t.input)).toEqual([file("/p/a.ts"), { kind: "agent-session", key: "agent:1", data: { task: "1" } }]);
|
||||
expect(pane.active).toBe(pane.tabs[1]!.id);
|
||||
// Only restorable tabs: still a session. Junk inputs (file kind, no key) are dropped.
|
||||
const empty = initialTabsState();
|
||||
const only = openTab(empty, empty.activePane, { kind: "agent-session", key: "agent:2", data: {} }).state;
|
||||
expect(serializeSession(only, {}, restorable)).not.toBeNull();
|
||||
expect(serializeSession(only, {})).toBeNull();
|
||||
const junk = parseSession({
|
||||
version: 1,
|
||||
layout: { kind: "pane", tabs: [{ input: { kind: "file", key: "k" } }, { input: { kind: "x" } }, { input: { kind: "x", key: "y" } }], active: 0 },
|
||||
})!;
|
||||
expect(junk.layout).toEqual({ kind: "pane", tabs: [{ input: { kind: "x", key: "y" } }], active: 0 });
|
||||
});
|
||||
|
||||
it("returns null for sessions without file tabs and rejects junk", () => {
|
||||
expect(serializeSession(initialTabsState(), {})).toBeNull();
|
||||
expect(parseSession({ version: 2 })).toBeNull();
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
/**
|
||||
* Editor session per project (IDEA's workspace.xml editor state): open file
|
||||
* tabs with pinned / preview flags and the active tab of every pane, the split
|
||||
* layout, the active pane and caret / scroll position per file. Serialised to
|
||||
* layout, the active pane and caret / scroll position per file. Tabs of editor
|
||||
* kinds that ask for it (`EditorKindContribution.restorable`, like IDEA's
|
||||
* FileEditorProvider state) are saved too, as their whole input. Serialised to
|
||||
* plain JSON (localStorage, keyed by project root) and rebuilt into a fresh
|
||||
* TabsState with new ids.
|
||||
*/
|
||||
import type { EditorInput } from "@/workbench/contributions";
|
||||
import { initialTabsState, paneIds, type LayoutNode, type Pane, type Tab, type TabsState } from "./tabs";
|
||||
|
||||
/** A file tab (`path`), or a tab of a restorable editor kind (`input`, with JSON `data`). */
|
||||
export interface SessionTab {
|
||||
path: string;
|
||||
path?: string;
|
||||
input?: EditorInput;
|
||||
pinned?: boolean;
|
||||
preview?: boolean;
|
||||
}
|
||||
@@ -34,18 +39,26 @@ export interface EditorSession {
|
||||
|
||||
const STORAGE_PREFIX = "openidea.editorSession.v1:";
|
||||
|
||||
export function serializeSession(s: TabsState, files: Record<string, FileViewState>): EditorSession | null {
|
||||
const isFileTab = (t: Tab) => t.input.kind === "file" && !!t.input.path;
|
||||
|
||||
/** `restorable`: whether a non-file tab is saved (its kind asked for it). */
|
||||
export function serializeSession(
|
||||
s: TabsState,
|
||||
files: Record<string, FileViewState>,
|
||||
restorable: (input: EditorInput) => boolean = () => false,
|
||||
): EditorSession | null {
|
||||
const saved = (t: Tab) => isFileTab(t) || (t.input.kind !== "file" && restorable(t.input));
|
||||
const walk = (n: LayoutNode): SessionLayout | null => {
|
||||
if (n.kind === "pane") {
|
||||
const pane = s.panes[n.paneId];
|
||||
if (!pane) return null;
|
||||
const fileTabs = pane.tabs.filter((t) => t.input.kind === "file" && !!t.input.path);
|
||||
if (!fileTabs.length) return null;
|
||||
const active = Math.max(0, fileTabs.findIndex((t) => t.id === pane.active));
|
||||
const kept = pane.tabs.filter(saved);
|
||||
if (!kept.length) return null;
|
||||
const active = Math.max(0, kept.findIndex((t) => t.id === pane.active));
|
||||
return {
|
||||
kind: "pane",
|
||||
tabs: fileTabs.map((t) => ({
|
||||
path: t.input.path!,
|
||||
tabs: kept.map((t) => ({
|
||||
...(isFileTab(t) ? { path: t.input.path! } : { input: { ...t.input } }),
|
||||
...(t.pinned ? { pinned: true } : {}),
|
||||
...(t.preview ? { preview: true } : {}),
|
||||
})),
|
||||
@@ -59,10 +72,11 @@ export function serializeSession(s: TabsState, files: Record<string, FileViewSta
|
||||
const layout = walk(s.layout);
|
||||
if (!layout) return null;
|
||||
// Active pane index among panes that survived serialisation.
|
||||
const kept = paneIds(s).filter((id) => s.panes[id]?.tabs.some((t) => t.input.kind === "file" && t.input.path));
|
||||
const kept = paneIds(s).filter((id) => s.panes[id]?.tabs.some(saved));
|
||||
const activePane = Math.max(0, kept.indexOf(s.activePane));
|
||||
const paths = new Set<string>();
|
||||
const collect = (l: SessionLayout) => (l.kind === "pane" ? l.tabs.forEach((t) => paths.add(t.path)) : l.children.forEach(collect));
|
||||
const collect = (l: SessionLayout) =>
|
||||
l.kind === "pane" ? l.tabs.forEach((t) => t.path && paths.add(t.path)) : l.children.forEach(collect);
|
||||
collect(layout);
|
||||
const keptFiles: Record<string, FileViewState> = {};
|
||||
for (const p of paths) if (files[p]) keptFiles[p] = files[p]!;
|
||||
@@ -78,9 +92,15 @@ export function slashPath(p: string): string {
|
||||
}
|
||||
|
||||
function isSessionTab(v: unknown): v is SessionTab {
|
||||
return !!v && typeof v === "object" && typeof (v as SessionTab).path === "string" && !!(v as SessionTab).path;
|
||||
if (!v || typeof v !== "object") return false;
|
||||
const t = v as SessionTab;
|
||||
if (typeof t.path === "string") return !!t.path;
|
||||
const i = t.input as Partial<EditorInput> | undefined;
|
||||
return !!i && typeof i.kind === "string" && i.kind !== "file" && typeof i.key === "string" && !!i.key;
|
||||
}
|
||||
|
||||
const tabKey = (t: SessionTab) => (t.path !== undefined ? `file:${t.path}` : `input:${t.input!.key}`);
|
||||
|
||||
/** Validates untrusted JSON; returns null when unusable. */
|
||||
export function parseSession(raw: unknown): EditorSession | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
@@ -90,7 +110,9 @@ export function parseSession(raw: unknown): EditorSession | null {
|
||||
if (!l || typeof l !== "object" || depth > 16) return null;
|
||||
const n = l as SessionLayout;
|
||||
if (n.kind === "pane") {
|
||||
const tabs = Array.isArray(n.tabs) ? n.tabs.filter(isSessionTab).map((t) => ({ ...t, path: slashPath(t.path) })) : [];
|
||||
const tabs = Array.isArray(n.tabs)
|
||||
? n.tabs.filter(isSessionTab).map((t) => (t.path !== undefined ? { ...t, path: slashPath(t.path) } : t))
|
||||
: [];
|
||||
if (!tabs.length) return null;
|
||||
const active = typeof n.active === "number" && n.active >= 0 && n.active < tabs.length ? Math.floor(n.active) : 0;
|
||||
return { kind: "pane", tabs, active };
|
||||
@@ -128,17 +150,23 @@ export function restoreTabsState(session: EditorSession, fileKey: (path: string)
|
||||
const seen = new Set<string>();
|
||||
const tabs: Tab[] = [];
|
||||
let preview = false;
|
||||
const byKey = new Map<string, Tab>();
|
||||
for (const t of l.tabs) {
|
||||
if (seen.has(t.path)) continue;
|
||||
seen.add(t.path);
|
||||
const key = tabKey(t);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
// At most one preview tab per pane; pinned tabs first.
|
||||
const isPreview: boolean = !!t.preview && !t.pinned && !preview;
|
||||
preview ||= isPreview;
|
||||
tabs.push({ id: id("tab"), input: { kind: "file", key: fileKey(t.path), path: t.path }, pinned: !!t.pinned, preview: isPreview });
|
||||
const input: EditorInput =
|
||||
t.path !== undefined ? { kind: "file", key: fileKey(t.path), path: t.path } : { ...t.input! };
|
||||
const tab: Tab = { id: id("tab"), input, pinned: !!t.pinned, preview: isPreview };
|
||||
byKey.set(key, tab);
|
||||
tabs.push(tab);
|
||||
}
|
||||
tabs.sort((a, b) => Number(b.pinned) - Number(a.pinned));
|
||||
const activePath = l.tabs[l.active]?.path;
|
||||
const active = tabs.find((t) => t.input.path === activePath) ?? tabs[0]!;
|
||||
const activeTab = l.tabs[l.active];
|
||||
const active = (activeTab && byKey.get(tabKey(activeTab))) ?? tabs[0]!;
|
||||
panes[paneId] = { id: paneId, tabs, active: active.id, mru: [active.id, ...tabs.filter((t) => t !== active).map((t) => t.id)] };
|
||||
order.push(paneId);
|
||||
return { kind: "pane", paneId };
|
||||
|
||||
@@ -31,12 +31,11 @@ import {
|
||||
SplitHandle,
|
||||
SplitPanel,
|
||||
Tabs,
|
||||
fileIconName,
|
||||
showContextMenu,
|
||||
type TabItem,
|
||||
type TabsDnd,
|
||||
} from "@/ui";
|
||||
import { editorController, isTextInput, useTabs, type VirtualInputData } from "../controller";
|
||||
import { editorController, isTextInput, tabIcon, tabTitle, useTabs, type VirtualInputData } from "../controller";
|
||||
import { useDocMeta } from "../model/document";
|
||||
import type { LayoutNode, SplitEdge, Tab } from "../model/tabs";
|
||||
import { disambiguateTitles } from "../model/tabTitles";
|
||||
@@ -115,28 +114,6 @@ function useTitleHints(): Map<string, string> {
|
||||
|
||||
const virtualData = (input: EditorInput) => input.data as VirtualInputData | undefined;
|
||||
|
||||
function tabTitle(input: EditorInput, kinds: ReturnType<typeof useContributions.getState>["editorKinds"]): string {
|
||||
if (input.kind === "file") return basename(input.path ?? input.key);
|
||||
if (input.kind === "virtual") return virtualData(input)?.title ?? basename(input.path ?? input.key);
|
||||
const kind = kinds.get(input.kind);
|
||||
try {
|
||||
return kind?.title(input) ?? input.key;
|
||||
} catch {
|
||||
return input.key;
|
||||
}
|
||||
}
|
||||
|
||||
function tabIcon(input: EditorInput, kinds: ReturnType<typeof useContributions.getState>["editorKinds"]): string {
|
||||
if (input.kind === "file") return fileIconName(basename(input.path ?? ""));
|
||||
if (input.kind === "virtual") return fileIconName(tabTitle(input, kinds));
|
||||
const kind = kinds.get(input.kind);
|
||||
try {
|
||||
return kind?.icon?.(input) ?? "file";
|
||||
} catch {
|
||||
return "file";
|
||||
}
|
||||
}
|
||||
|
||||
function PaneView({ paneId }: { paneId: string }) {
|
||||
const ctrl = editorController();
|
||||
const pane = useTabs((s) => s.panes[paneId]);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
.view {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.view > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.groupLabel {
|
||||
color: var(--fg-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.count {
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-tiny);
|
||||
}
|
||||
.title {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.detail {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.age {
|
||||
flex: none;
|
||||
padding-right: 8px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ready {
|
||||
color: var(--success);
|
||||
}
|
||||
.failed {
|
||||
color: var(--danger);
|
||||
}
|
||||
.archived {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 24px 12px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.link {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover {
|
||||
color: var(--link-hover);
|
||||
}
|
||||
|
||||
.statusItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
padding: 0 6px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-small);
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.statusItem:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.statusPart {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Status bar widget: tasks waiting for the user / running; opens the most urgent one. */
|
||||
import { Icon, Spinner, T } from "@/ui";
|
||||
import { openTask, useAgents } from "./store";
|
||||
import styles from "./Agents.module.css";
|
||||
|
||||
export function AgentsStatus() {
|
||||
const tasks = useAgents((s) => s.tasks);
|
||||
const list = [...tasks.values()];
|
||||
const input = list.filter((t) => t.status === "needsInput");
|
||||
const running = list.filter((t) => t.status === "running" || t.status === "starting").length;
|
||||
if (!input.length && !running) return null;
|
||||
const target = input[0] ?? list.find((t) => t.status === "running");
|
||||
return (
|
||||
<button type="button" className={styles.statusItem} onClick={() => target && openTask(target.id)}>
|
||||
{input.length > 0 && (
|
||||
<span className={styles.statusPart}>
|
||||
<Icon name="warning" size={13} />
|
||||
{input.length} <T>needs input</T>
|
||||
</span>
|
||||
)}
|
||||
{running > 0 && (
|
||||
<span className={styles.statusPart}>
|
||||
<Spinner size={10} />
|
||||
{running} <T>running</T>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/** Agents tool window: tasks grouped by what they need from the user. */
|
||||
import { useMemo, useState } from "react";
|
||||
import type { AgentStatus as Status, AgentTask } from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { confirmChecked } from "@/workbench/messages";
|
||||
import { Icon, IconButton, Spinner, T, Tree, showContextMenu, type MenuItem } from "@/ui";
|
||||
import { age, branchLabel, groupTasks, isBusy, type TaskGroup } from "./model";
|
||||
import { archiveTask, deleteTask, openHome, openTask, useAgents } from "./store";
|
||||
import styles from "./Agents.module.css";
|
||||
|
||||
export function AgentsHeaderActions() {
|
||||
return <IconButton icon="plus" action="agents.newTask" size="small" />;
|
||||
}
|
||||
|
||||
type Node = { kind: "group"; group: TaskGroup } | { kind: "task"; task: AgentTask };
|
||||
|
||||
const nodeKey = (n: Node) => (n.kind === "group" ? `g:${n.group.status}` : n.task.id);
|
||||
|
||||
export function TaskStatusIcon({ status }: { status: Status }) {
|
||||
switch (status) {
|
||||
case "needsInput":
|
||||
return <Icon name="warning" size={13} label="Needs input" />;
|
||||
case "running":
|
||||
case "starting":
|
||||
return <Spinner size={11} />;
|
||||
case "ready":
|
||||
return <Icon name="check" size={13} className={styles.ready} label="Ready" />;
|
||||
case "failed":
|
||||
return <Icon name="close" size={13} className={styles.failed} label="Failed" />;
|
||||
case "archived":
|
||||
return <Icon name="minus" size={13} className={styles.archived} label="Archived" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function AgentsView() {
|
||||
const loaded = useAgents((s) => s.loaded);
|
||||
const tasks = useAgents((s) => s.tasks);
|
||||
const providers = useAgents((s) => s.providers);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["g:archived"]));
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
const roots = useMemo<Node[]>(() => groupTasks(tasks.values()).map((group) => ({ kind: "group", group })), [tasks]);
|
||||
const expanded = useMemo(() => new Set(roots.map(nodeKey).filter((k) => !collapsed.has(k))), [roots, collapsed]);
|
||||
if (!loaded) return <Spinner />;
|
||||
const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id;
|
||||
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<Tree<Node>
|
||||
aria-label="Agent tasks"
|
||||
roots={roots}
|
||||
getKey={nodeKey}
|
||||
getLabel={(n) => (n.kind === "group" ? n.group.label : n.task.title)}
|
||||
getChildren={(n) => (n.kind === "group" ? n.group.tasks.map((task) => ({ kind: "task", task }) as Node) : undefined)}
|
||||
isExpandable={(n) => n.kind === "group"}
|
||||
expanded={expanded}
|
||||
onExpandedChange={(_next, node, open) =>
|
||||
setCollapsed((c) => {
|
||||
const s = new Set(c);
|
||||
if (open) s.delete(nodeKey(node));
|
||||
else s.add(nodeKey(node));
|
||||
return s;
|
||||
})
|
||||
}
|
||||
selected={selected}
|
||||
onSelectionChange={(sel, lead) => {
|
||||
setSelected(sel);
|
||||
if (lead?.kind === "task") openTask(lead.task.id);
|
||||
}}
|
||||
onActivate={(n) => n.kind === "task" && openTask(n.task.id)}
|
||||
onContextMenu={(n, _sel, e) => n?.kind === "task" && showContextMenu(e, taskMenu(n.task))}
|
||||
renderNode={(n) =>
|
||||
n.kind === "group" ? (
|
||||
<span className={styles.row}>
|
||||
<span className={styles.groupLabel}>
|
||||
<T>{n.group.label}</T>
|
||||
</span>
|
||||
<span className={styles.count}>{n.group.tasks.length}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.row} title={n.task.error ?? n.task.title}>
|
||||
<TaskStatusIcon status={n.task.status} />
|
||||
<span className={styles.title}>{n.task.title}</span>
|
||||
<span className={styles.detail}>
|
||||
{providerName(n.task.provider)}
|
||||
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
|
||||
</span>
|
||||
<span className={styles.age}>{age(n.task.updatedMs ?? 0)}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
empty={
|
||||
<div className={styles.empty}>
|
||||
<T>No agent tasks yet</T>
|
||||
<button type="button" className={styles.link} onClick={() => openHome()}>
|
||||
<T>New Task…</T>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function taskMenu(task: AgentTask): MenuItem[] {
|
||||
const busy = isBusy(task.status);
|
||||
return [
|
||||
{ label: "Open", run: () => openTask(task.id) },
|
||||
{ label: "Open in Terminal", icon: "terminal", run: () => void executeAction("terminal.openAt", task.cwd) },
|
||||
{ kind: "separator" },
|
||||
{ label: "Archive", disabled: busy || task.status === "archived", run: () => void archiveTask(task.id) },
|
||||
{ label: "Delete…", run: () => void confirmDelete(task) },
|
||||
];
|
||||
}
|
||||
|
||||
async function confirmDelete(task: AgentTask) {
|
||||
const worktree = task.isolation === "worktree";
|
||||
const r = await confirmChecked({
|
||||
title: "Delete Agent Task",
|
||||
message: worktree
|
||||
? `Delete “${task.title}”? Its branch ${task.branch ?? ""} is kept.`
|
||||
: `Delete “${task.title}”? Files it changed are kept.`,
|
||||
ok: "Delete",
|
||||
icon: "warning",
|
||||
checkbox: worktree ? "Also remove its worktree folder" : undefined,
|
||||
checked: worktree,
|
||||
});
|
||||
if (r) await deleteTask(task.id, worktree && r.checked);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/* A framed text area (IDEA text field frame), with the pickers in its bottom row. */
|
||||
.composer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-popup);
|
||||
background: var(--bg-field, var(--bg-button));
|
||||
box-shadow: inset 0 0 0 1px var(--border-button);
|
||||
cursor: text;
|
||||
transition: box-shadow var(--dur-fast) var(--ease);
|
||||
}
|
||||
.composer:focus-within {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--focus-ring),
|
||||
0 0 0 1px var(--focus-ring);
|
||||
}
|
||||
.textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
resize: none;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--fg-strong);
|
||||
font: inherit;
|
||||
font-size: var(--fs-ui);
|
||||
line-height: 1.5;
|
||||
padding: 10px 12px 2px;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
.textarea:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.textarea::placeholder {
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 6px 6px;
|
||||
cursor: default;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.pickerValue {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pickerName {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
.images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 10px 0;
|
||||
cursor: default;
|
||||
}
|
||||
.image {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-small);
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.removeImage {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-popup);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
.image:hover .removeImage,
|
||||
.removeImage:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* `@` / `/` completion, above the box like a code-completion popup. */
|
||||
.popup {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 4px);
|
||||
z-index: 10;
|
||||
width: min(560px, 100%);
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
padding: 4px 0;
|
||||
border: 1px solid var(--border-popup);
|
||||
border-radius: var(--radius-popup);
|
||||
background: var(--bg-popup);
|
||||
box-shadow: var(--shadow-popup);
|
||||
cursor: default;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: var(--h-row);
|
||||
margin: 0 4px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-row);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.active {
|
||||
background: var(--bg-list-selected);
|
||||
}
|
||||
.itemName {
|
||||
flex: none;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.itemHint {
|
||||
flex: none;
|
||||
color: var(--fg-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.itemDetail {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* The message box of the New Task page and of sessions: `@` file references (project file
|
||||
* search), `/` agent commands, ↑ / ↓ history, pasted or dropped images, Shift+Tab to cycle
|
||||
* the agent's mode. Its draft lives in the store under `id`.
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { AgentCommand, AgentConfigOption, AgentImage } from "@/bindings";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { basename } from "@/platform/project";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { Icon, IconButton, fileIconName } from "@/ui";
|
||||
import {
|
||||
IMAGE_EXTENSIONS,
|
||||
MAX_IMAGE_BYTES,
|
||||
applyCompletion,
|
||||
atEdgeLine,
|
||||
completionAt,
|
||||
imageFromDataUrl,
|
||||
matchCommands,
|
||||
mentionOf,
|
||||
modeOption,
|
||||
nextValue,
|
||||
recall,
|
||||
type Completion,
|
||||
type FileRef,
|
||||
} from "./composerModel";
|
||||
import { composerDraft, updateComposer, useAgents } from "./store";
|
||||
import styles from "./Composer.module.css";
|
||||
|
||||
const MAX_PROMPT_HEIGHT = 280;
|
||||
const MAX_ITEMS = 12;
|
||||
|
||||
type Item =
|
||||
| { kind: "file"; path: string; rel: string }
|
||||
| { kind: "command"; command: AgentCommand };
|
||||
|
||||
export interface ComposerProps {
|
||||
/** Store key of the draft. */
|
||||
id: string;
|
||||
placeholder: string;
|
||||
/** Directory `@` references resolve in (the task's worktree, or the project root). */
|
||||
root: string;
|
||||
/** The project root the file index covers. */
|
||||
projectRoot: string;
|
||||
commands: readonly AgentCommand[];
|
||||
/** Sent messages, newest first. */
|
||||
history: readonly string[];
|
||||
/** 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). */
|
||||
onSubmit: (now: boolean) => void;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
/** Left of the bottom row: agent / model / mode pickers. */
|
||||
toolbar?: ReactNode;
|
||||
/** Right of the bottom row: send / stop buttons. */
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Composer(props: ComposerProps) {
|
||||
const { id, root, projectRoot } = props;
|
||||
const tx = useTx();
|
||||
const draft = useAgents((s) => s.composers.get(id));
|
||||
const text = draft?.text ?? "";
|
||||
const images = draft?.images ?? [];
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const [completion, setCompletion] = useState<Completion | null>(null);
|
||||
const [files, setFiles] = useState<Item[]>([]);
|
||||
const [active, setActive] = useState(0);
|
||||
const recalled = useRef<{ index: number; stash: string }>({ index: -1, stash: "" });
|
||||
|
||||
useEffect(() => {
|
||||
if (props.autoFocus) ref.current?.focus();
|
||||
}, [props.autoFocus]);
|
||||
|
||||
// Grow with the text, up to a limit.
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, MAX_PROMPT_HEIGHT)}px`;
|
||||
}, [text]);
|
||||
|
||||
// `@` completion: project file search, mapped into the task's directory.
|
||||
useEffect(() => {
|
||||
if (completion?.kind !== "file") return;
|
||||
let alive = true;
|
||||
const timer = setTimeout(() => {
|
||||
call(commands.findFiles(completion.query, MAX_ITEMS))
|
||||
.then((found) => {
|
||||
if (!alive) return;
|
||||
setFiles(
|
||||
found.map((f) => ({
|
||||
kind: "file",
|
||||
rel: f.relPath,
|
||||
path: root === projectRoot ? f.path : `${root}/${f.relPath}`,
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => alive && setFiles([]));
|
||||
}, 60);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [completion?.kind, completion?.query, root, projectRoot]);
|
||||
|
||||
const items: Item[] = useMemo(() => {
|
||||
if (!completion) return [];
|
||||
if (completion.kind === "file") return files;
|
||||
return matchCommands(props.commands, completion.query)
|
||||
.slice(0, MAX_ITEMS)
|
||||
.map((command) => ({ kind: "command", command }));
|
||||
}, [completion, files, props.commands]);
|
||||
const open = !!completion && items.length > 0;
|
||||
|
||||
const setText = (value: string, caret?: number) => {
|
||||
updateComposer(id, { text: value });
|
||||
const c = caret ?? value.length;
|
||||
setCompletion(completionAt(value, c));
|
||||
setActive(0);
|
||||
if (caret !== undefined) {
|
||||
requestAnimationFrame(() => {
|
||||
ref.current?.focus();
|
||||
ref.current?.setSelectionRange(caret, caret);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const accept = (item: Item | undefined) => {
|
||||
if (!item || !completion) return;
|
||||
if (item.kind === "file") {
|
||||
const mention = mentionOf(item.rel);
|
||||
const next = applyCompletion(text, completion, mention);
|
||||
const current = composerDraft(id);
|
||||
updateComposer(id, { files: [...current.files, { path: item.path, rel: item.rel } satisfies FileRef] });
|
||||
setText(next.text, next.caret);
|
||||
} else {
|
||||
const next = applyCompletion(text, completion, `/${item.command.name}`);
|
||||
setText(next.text, next.caret);
|
||||
}
|
||||
setCompletion(null);
|
||||
};
|
||||
|
||||
const addImages = (list: AgentImage[]) => {
|
||||
if (list.length) updateComposer(id, { images: [...composerDraft(id).images, ...list] });
|
||||
};
|
||||
|
||||
const readImageFiles = (picked: Iterable<File>) => {
|
||||
for (const file of picked) {
|
||||
if (!file.type.startsWith("image/")) continue;
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
notifications().notify({ level: "warning", title: "Image too large", body: `${file.name} is over 5 MB.` });
|
||||
continue;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const image = typeof reader.result === "string" ? imageFromDataUrl(reader.result, file.name || "image") : null;
|
||||
if (image) addImages([image]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Files dropped from the OS onto the box: images are attached, other files referenced.
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
void import("@tauri-apps/api/webview").then(({ getCurrentWebview }) =>
|
||||
getCurrentWebview()
|
||||
.onDragDropEvent((event) => {
|
||||
const p = event.payload;
|
||||
if (p.type !== "drop" || !p.paths.length) return;
|
||||
const box = boxRef.current?.getBoundingClientRect();
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const [x, y] = [p.position.x / ratio, p.position.y / ratio];
|
||||
if (!box || x < box.left || x > box.right || y < box.top || y > box.bottom) return;
|
||||
const refs: FileRef[] = [];
|
||||
for (const path of p.paths) {
|
||||
if (IMAGE_EXTENSIONS.test(path)) {
|
||||
void call(commands.fsReadImage(path)).then((url) => {
|
||||
const image = imageFromDataUrl(url, basename(path));
|
||||
if (image) addImages([image]);
|
||||
});
|
||||
} else {
|
||||
refs.push({ path, rel: path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path });
|
||||
}
|
||||
}
|
||||
if (refs.length) {
|
||||
const current = composerDraft(id);
|
||||
const mentions = refs.map((r) => mentionOf(r.rel)).join(" ");
|
||||
const glue = current.text && !/\s$/.test(current.text) ? " " : "";
|
||||
updateComposer(id, { text: `${current.text}${glue}${mentions} `, files: [...current.files, ...refs] });
|
||||
}
|
||||
})
|
||||
.then((u) => {
|
||||
if (disposed) u();
|
||||
else unlisten = u;
|
||||
}),
|
||||
);
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-subscribe only when the target changes
|
||||
}, [id, root]);
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (open) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const d = e.key === "ArrowDown" ? 1 : -1;
|
||||
setActive((a) => (a + d + items.length) % items.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
accept(items[active]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCompletion(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (!props.disabled) props.onSubmit(e.metaKey || e.ctrlKey);
|
||||
recalled.current = { index: -1, stash: "" };
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" && e.shiftKey) {
|
||||
const mode = modeOption(props.config);
|
||||
const value = mode && nextValue(mode);
|
||||
if (mode && value && props.onConfig) {
|
||||
e.preventDefault();
|
||||
props.onConfig(mode.id, value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !e.altKey && !e.metaKey) {
|
||||
const dir = e.key === "ArrowUp" ? -1 : 1;
|
||||
const el = e.currentTarget;
|
||||
const r = recalled.current;
|
||||
// Only from an empty box or one showing a recalled message, with the caret on the edge line.
|
||||
const fresh = r.index === -1 ? text === "" : text === props.history[r.index];
|
||||
if (!fresh || !atEdgeLine(text, el.selectionStart, dir)) return;
|
||||
const next = recall(props.history, r.index, dir);
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
if (r.index === -1) r.stash = text;
|
||||
r.index = next;
|
||||
const value = next === -1 ? r.stash : props.history[next]!;
|
||||
setText(value, value.length);
|
||||
setCompletion(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={boxRef} className={clsx(styles.composer, props.className)} onClick={() => ref.current?.focus()}>
|
||||
{open && (
|
||||
<div className={styles.popup} role="listbox" aria-label={tx(completion.kind === "file" ? "Files" : "Commands")}>
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={item.kind === "file" ? item.path : item.command.name}
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
className={clsx(styles.item, i === active && styles.active)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
accept(item);
|
||||
}}
|
||||
>
|
||||
{item.kind === "file" ? (
|
||||
<>
|
||||
<Icon name={fileIconName(basename(item.rel))} size={16} />
|
||||
<span className={styles.itemName}>{basename(item.rel)}</span>
|
||||
<span className={styles.itemDetail}>{item.rel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.itemName}>/{item.command.name}</span>
|
||||
{item.command.hint && <span className={styles.itemHint}>{item.command.hint}</span>}
|
||||
<span className={styles.itemDetail}>{item.command.description}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{images.length > 0 && (
|
||||
<div className={styles.images} onClick={(e) => e.stopPropagation()}>
|
||||
{images.map((img, i) => (
|
||||
<span key={i} className={styles.image} title={img.name ?? undefined}>
|
||||
<img src={`data:${img.mimeType};base64,${img.data}`} alt={img.name ?? tx("Image")} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeImage}
|
||||
aria-label={tx("Remove Image")}
|
||||
onClick={() => updateComposer(id, { images: images.filter((_, j) => j !== i) })}
|
||||
>
|
||||
<Icon name="close" size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={styles.textarea}
|
||||
rows={2}
|
||||
value={text}
|
||||
aria-label={tx("Message")}
|
||||
placeholder={tx(props.placeholder)}
|
||||
onChange={(e) => setText(e.target.value, undefined)}
|
||||
onSelect={(e) => setCompletion(completionAt(e.currentTarget.value, e.currentTarget.selectionStart))}
|
||||
onBlur={() => setCompletion(null)}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={(e) => {
|
||||
const pasted = [...e.clipboardData.files].filter((f) => f.type.startsWith("image/"));
|
||||
if (pasted.length) {
|
||||
e.preventDefault();
|
||||
readImageFiles(pasted);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className={styles.bar} onClick={(e) => e.stopPropagation()}>
|
||||
{props.toolbar}
|
||||
<span className={styles.spacer} />
|
||||
<IconButton
|
||||
icon="file"
|
||||
title="Reference a File (@)"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const glue = text && !/\s$/.test(text) ? " " : "";
|
||||
setText(`${text}${glue}@`, text.length + glue.length + 1);
|
||||
}}
|
||||
/>
|
||||
<IconButton icon="file-image" title="Attach Image…" size="small" onClick={() => imageInput.current?.click()} />
|
||||
<input
|
||||
ref={imageInput}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
readImageFiles(e.target.files ?? []);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{props.actions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Model / mode / thinking-effort dropdowns for a composer's bottom row. Like other agent
|
||||
* UIs, only the mode gets icons (one per mode, by what it allows); levels are plain text.
|
||||
*/
|
||||
import type { AgentConfigOption } from "@/bindings";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { Select } from "@/ui";
|
||||
import { mainOptions, modeIcon } from "./composerModel";
|
||||
import styles from "./Composer.module.css";
|
||||
|
||||
export function ConfigPickers({
|
||||
config,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
config: readonly AgentConfigOption[];
|
||||
/** Chosen values that override `current`, by option id. */
|
||||
values?: Record<string, string>;
|
||||
onChange: (option: string, value: string) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
return (
|
||||
<>
|
||||
{mainOptions(config).map((o) => {
|
||||
const isMode = o.category === "mode";
|
||||
const value = values?.[o.id] ?? o.current ?? null;
|
||||
return (
|
||||
<Select
|
||||
key={o.id}
|
||||
variant="ghost"
|
||||
aria-label={o.name}
|
||||
value={value}
|
||||
placeholder={tx(o.name)}
|
||||
options={o.values.map((v) => ({
|
||||
value: v.value,
|
||||
label: v.name,
|
||||
icon: isMode ? modeIcon(v.value) : undefined,
|
||||
detail: v.description ?? undefined,
|
||||
}))}
|
||||
onChange={(v) => onChange(o.id, v)}
|
||||
// Effort and other levels say what they are: "Effort Medium".
|
||||
renderValue={
|
||||
o.category === "thought_level"
|
||||
? (current) => (
|
||||
<span className={styles.pickerValue}>
|
||||
<span className={styles.pickerName}>{o.name}</span>
|
||||
{current?.label ?? tx("Default")}
|
||||
</span>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.home {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 24px 12vh;
|
||||
background: var(--bg-editor);
|
||||
color: var(--fg);
|
||||
}
|
||||
.inner {
|
||||
width: min(720px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.heading {
|
||||
margin: 0 0 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: var(--fg-strong);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Under the box: the project and where the agent works. */
|
||||
.where {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 2px;
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.project {
|
||||
margin-right: 6px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.note,
|
||||
.warning {
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-subtle);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.warning {
|
||||
color: var(--danger);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/** New Task page: one prompt box with the agent and its settings; where it works under it. */
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Branch } from "@/bindings";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { useTx, useTxTemplate } from "@/platform/i18n";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { Button, Select, T, type SelectOption } from "@/ui";
|
||||
import { Composer } from "./Composer";
|
||||
import { canSend, emptyDraft } from "./composerModel";
|
||||
import { ConfigPickers } from "./ConfigPickers";
|
||||
import { providerIcon } from "./model";
|
||||
import { HOME, startTask, updateDraft, useAgents } from "./store";
|
||||
import styles from "./Home.module.css";
|
||||
|
||||
export function HomeEditor() {
|
||||
const tx = useTx();
|
||||
const txTemplate = useTxTemplate();
|
||||
const draft = useAgents((s) => s.draft);
|
||||
const providers = useAgents((s) => s.providers);
|
||||
const history = useAgents((s) => s.history);
|
||||
const composer = useAgents((s) => s.composers.get(HOME)) ?? emptyDraft();
|
||||
const project = useProject((s) => s.project);
|
||||
const [branches, setBranches] = useState<Branch[]>([]);
|
||||
const [current, setCurrent] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
call(commands.vcsBranches(null))
|
||||
.then((list) => {
|
||||
const local = list.filter((b) => !b.remote);
|
||||
setBranches(local);
|
||||
setCurrent(local.find((b) => b.current)?.name ?? null);
|
||||
})
|
||||
.catch(() => setBranches([]));
|
||||
}, []);
|
||||
|
||||
const provider = providers.find((p) => p.id === draft.provider) ?? providers[0];
|
||||
// Installed agents first; the rest under a "Not installed" title, with the command they need.
|
||||
const installed = providers.filter((p) => p.installed);
|
||||
const missing = providers.filter((p) => !p.installed);
|
||||
const providerOptions: SelectOption[] = [
|
||||
...installed.map((p) => ({ value: p.id, label: p.name, icon: providerIcon(p.id) })),
|
||||
...(missing.length ? [{ value: "__missing__", label: tx("Not installed"), disabled: true }] : []),
|
||||
...missing.map((p) => ({ value: p.id, label: p.name, icon: providerIcon(p.id), detail: p.command })),
|
||||
];
|
||||
const base = draft.base ?? current;
|
||||
const canStart = canSend(composer) && !!provider?.installed && !starting;
|
||||
const root = project?.root ?? "";
|
||||
|
||||
const start = () => {
|
||||
if (!canStart) return;
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
startTask()
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setStarting(false));
|
||||
};
|
||||
const setOption = (id: string, value: string) => updateDraft({ config: { ...draft.config, [id]: value } });
|
||||
|
||||
return (
|
||||
<div className={styles.home}>
|
||||
<div className={styles.inner}>
|
||||
<h1 className={styles.heading}>
|
||||
<T>What should the agent do?</T>
|
||||
</h1>
|
||||
<Composer
|
||||
id={HOME}
|
||||
autoFocus
|
||||
placeholder="Describe a task for the agent. @ references a file, / runs a command"
|
||||
root={root}
|
||||
projectRoot={root}
|
||||
commands={provider?.commands ?? []}
|
||||
history={history}
|
||||
config={provider?.config ?? []}
|
||||
onConfig={setOption}
|
||||
onSubmit={start}
|
||||
disabled={!canStart}
|
||||
toolbar={
|
||||
<>
|
||||
<Select
|
||||
variant="ghost"
|
||||
aria-label="Agent"
|
||||
value={provider?.id ?? null}
|
||||
options={providerOptions}
|
||||
onChange={(id) => updateDraft({ provider: id, config: {} })}
|
||||
/>
|
||||
<ConfigPickers config={provider?.config ?? []} values={draft.config} onChange={setOption} />
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Button variant="primary" size="small" disabled={!canStart} onClick={start}>
|
||||
{starting ? "Starting…" : "Start"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className={styles.where}>
|
||||
<span className={styles.project}>{project?.name}</span>
|
||||
<Select
|
||||
variant="ghost"
|
||||
aria-label="Isolation"
|
||||
value={draft.isolation}
|
||||
options={[
|
||||
{ value: "worktree", label: tx("New worktree"), icon: "branch", detail: tx("away from your changes") },
|
||||
{ value: "local", label: tx("Local checkout"), icon: "folder", detail: tx("edits your files directly") },
|
||||
]}
|
||||
onChange={(isolation) => updateDraft({ isolation })}
|
||||
/>
|
||||
{draft.isolation === "worktree" && branches.length > 0 && (
|
||||
<>
|
||||
<span className={styles.note}>
|
||||
<T>from</T>
|
||||
</span>
|
||||
<Select
|
||||
variant="ghost"
|
||||
aria-label="Base branch"
|
||||
value={base}
|
||||
options={branches.map((b) => ({ value: b.name, label: b.name }))}
|
||||
onChange={(b) => updateDraft({ base: b === current ? null : b })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(error || (provider && !provider.installed) || (provider && !provider.config.length)) && (
|
||||
<p className={error || !provider?.installed ? styles.warning : styles.note} role={error ? "alert" : undefined}>
|
||||
{error ??
|
||||
(provider && !provider.installed ? (
|
||||
txTemplate(`${provider.command} was not found on PATH`)
|
||||
) : (
|
||||
<T>Models and modes appear after this agent's first task.</T>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Agent messages: Markdown → sanitized HTML (marked + DOMPurify). */
|
||||
import { useMemo } from "react";
|
||||
import { Marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { openExternalUrl } from "@/platform/openUrl";
|
||||
import styles from "./Session.module.css";
|
||||
|
||||
const marked = new Marked({ gfm: true, breaks: true });
|
||||
|
||||
export function markdownHtml(text: string): string {
|
||||
const html = marked.parse(text, { async: false }) as string;
|
||||
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
|
||||
}
|
||||
|
||||
export function Markdown({ text }: { text: string }) {
|
||||
const html = useMemo(() => markdownHtml(text), [text]);
|
||||
return (
|
||||
<div
|
||||
className={styles.markdown}
|
||||
onClick={(e) => {
|
||||
const a = (e.target as HTMLElement).closest("a");
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
if (/^https?:/i.test(a.href)) void openExternalUrl(a.href);
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
.session {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
background: var(--bg-editor);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.conversation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.title {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.headerDetail {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
color: var(--fg-subtle);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.providerIcon {
|
||||
vertical-align: -2px;
|
||||
margin-right: 4px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.muted {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
/* Pending permission: an editor notification banner. */
|
||||
.banner {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-feedback-warning);
|
||||
border-bottom: 1px solid var(--border-feedback-warning);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.bannerText {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bannerSpacer {
|
||||
flex: 1;
|
||||
}
|
||||
.bannerLink {
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bannerLink:hover:not(:disabled) {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.bannerLink:disabled {
|
||||
color: var(--fg-subtle);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.stream {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 14px 16px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user {
|
||||
padding: 2px 0 2px 10px;
|
||||
border-left: 2px solid var(--accent);
|
||||
color: var(--fg-strong);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.markdown {
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.markdown :global(p) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.markdown :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown :global(code) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.markdown :global(pre) {
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-elev);
|
||||
overflow: auto;
|
||||
}
|
||||
.markdown :global(pre code) {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
.markdown :global(ul),
|
||||
.markdown :global(ol) {
|
||||
margin: 0 0 8px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.markdown :global(a) {
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.thought summary,
|
||||
.toolDetails > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.thought summary::-webkit-details-marker,
|
||||
.toolDetails > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.thought summary {
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.thoughtText {
|
||||
margin-top: 4px;
|
||||
padding-left: 10px;
|
||||
border-left: 2px solid var(--border-strong);
|
||||
color: var(--fg-secondary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 2px 8px;
|
||||
margin-left: -8px;
|
||||
border-radius: var(--radius-row);
|
||||
color: var(--fg-secondary);
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
.toolDetails > summary.tool:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.toolTitle {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.toolDiff {
|
||||
font: inherit;
|
||||
font-size: var(--fs-small);
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0 4px;
|
||||
border-radius: var(--radius-small);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.toolDiff:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.output {
|
||||
margin: 4px 0 0;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-small);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elev);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
white-space: pre-wrap;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
.plan {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.planTitle {
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.planItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.planDone {
|
||||
color: var(--fg-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.permissionLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.permissionTitle {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
color: var(--danger);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.working {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.composerArea {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 6px 12px 12px;
|
||||
}
|
||||
|
||||
.queue {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.queued {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
padding: 0 4px 0 8px;
|
||||
border-radius: var(--radius-row);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.queuedText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.usageBar {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.usageBar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--fg-muted);
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
margin-top: 4px;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.attachment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.changes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.changesHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
flex: none;
|
||||
padding: 0 6px 0 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.changesTitle {
|
||||
font-weight: 600;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.changesSummary {
|
||||
flex: none;
|
||||
padding: 4px 10px;
|
||||
color: var(--fg-subtle);
|
||||
white-space: pre;
|
||||
}
|
||||
.changesList {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.changesList > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.changesEmpty {
|
||||
padding: 8px 10px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.change {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.changeName {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.changeDir {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding-left: 4px;
|
||||
color: var(--fg-subtle);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.changeStat {
|
||||
flex: none;
|
||||
padding-right: 8px;
|
||||
font-size: var(--fs-small);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.added {
|
||||
color: var(--vcs-added);
|
||||
}
|
||||
.removed {
|
||||
color: var(--danger);
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
/** An agent task as an editor tab: the conversation, a composer and the task's changed files. */
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { AgentChange, AgentEntry, AgentTask } from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { basename, relativeDir, useProject } from "@/platform/project";
|
||||
import { editorService, notifications, type EditorInput } from "@/workbench/contributions";
|
||||
import { Button, EmptyState, Icon, IconButton, Spinner, T, Tree, fileIconName } from "@/ui";
|
||||
import { TaskStatusIcon } from "./AgentsView";
|
||||
import { Composer } from "./Composer";
|
||||
import { canSend, emptyDraft, usageLabel } from "./composerModel";
|
||||
import { ConfigPickers } from "./ConfigPickers";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { branchLabel, isBusy, providerIcon, statusLabel } from "./model";
|
||||
import {
|
||||
archiveTask,
|
||||
cancelTask,
|
||||
loadEntries,
|
||||
markVisible,
|
||||
refreshChanges,
|
||||
editQueued,
|
||||
removeQueued,
|
||||
respond,
|
||||
sendMessage,
|
||||
sendQueuedNow,
|
||||
setTaskConfig,
|
||||
useAgents,
|
||||
} from "./store";
|
||||
import styles from "./Session.module.css";
|
||||
|
||||
export function SessionEditor({ input }: { input: EditorInput }) {
|
||||
const id = (input.data as { task: string }).task;
|
||||
const task = useAgents((s) => s.tasks.get(id));
|
||||
const entries = useAgents((s) => s.entries.get(id));
|
||||
const changes = useAgents((s) => s.changes.get(id));
|
||||
useEffect(() => {
|
||||
void loadEntries(id).catch(() => undefined);
|
||||
void refreshChanges(id);
|
||||
return markVisible(id);
|
||||
}, [id]);
|
||||
if (!task) return <EmptyState title="This agent task no longer exists" />;
|
||||
return (
|
||||
<div className={styles.session}>
|
||||
<div className={styles.conversation}>
|
||||
<Header task={task} />
|
||||
<PermissionBanner task={task} entries={entries ?? null} />
|
||||
<Stream task={task} entries={entries ?? null} />
|
||||
<SessionComposer task={task} entries={entries ?? null} />
|
||||
</div>
|
||||
<ChangesPane task={task} changes={changes ?? null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function providerName(id: string) {
|
||||
return useAgents.getState().providers.find((p) => p.id === id)?.name ?? id;
|
||||
}
|
||||
|
||||
function Header({ task }: { task: AgentTask }) {
|
||||
const busy = isBusy(task.status);
|
||||
const where = task.isolation === "worktree" ? branchLabel(task.branch) : null;
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<TaskStatusIcon status={task.status} />
|
||||
<span className={styles.title} title={task.title}>
|
||||
{task.title}
|
||||
</span>
|
||||
<span className={styles.headerDetail} title={task.cwd}>
|
||||
<Icon name={providerIcon(task.provider)} size={13} className={styles.providerIcon} />
|
||||
{providerName(task.provider)}
|
||||
{task.model ? ` · ${task.model}` : ""}
|
||||
{" · "}
|
||||
{where ?? <T>Local checkout</T>}
|
||||
{" · "}
|
||||
<T>{statusLabel(task.status)}</T>
|
||||
</span>
|
||||
{task.usage && <Usage task={task} />}
|
||||
{busy && <IconButton icon="stop" title="Stop" size="small" onClick={() => void cancelTask(task.id)} />}
|
||||
<IconButton
|
||||
icon="terminal"
|
||||
title="Open in Terminal"
|
||||
size="small"
|
||||
onClick={() => void executeAction("terminal.openAt", task.cwd)}
|
||||
/>
|
||||
{!busy && task.status !== "archived" && (
|
||||
<IconButton icon="close" title="Archive Task" size="small" onClick={() => void archiveTask(task.id)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Context window use: "12%", the numbers (and cost) in the tooltip. */
|
||||
function Usage({ task }: { task: AgentTask }) {
|
||||
const label = usageLabel(task.usage!);
|
||||
return (
|
||||
<span className={styles.usage} title={label.detail} aria-label={`Context ${label.short}, ${label.detail}`}>
|
||||
<span className={styles.usageBar} aria-hidden>
|
||||
<span style={{ width: label.short }} />
|
||||
</span>
|
||||
{label.short}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Stream({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const stick = useRef(true);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
const waiting = entries?.some((e) => e.type === "permission" && !e.chosen);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.stream}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
}}
|
||||
>
|
||||
{entries === null ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
entries.map((entry, i) => <Entry key={i} task={task} entry={entry} />)
|
||||
)}
|
||||
{(task.status === "running" || task.status === "starting") && !waiting && (
|
||||
<div className={styles.working}>
|
||||
<Spinner size={12} />
|
||||
<T>{task.status === "starting" ? "Starting the agent…" : "Working…"}</T>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TOOL_ICONS: Record<string, string> = {
|
||||
read: "file",
|
||||
edit: "edit",
|
||||
delete: "minus",
|
||||
move: "edit",
|
||||
search: "search",
|
||||
execute: "terminal",
|
||||
fetch: "web",
|
||||
think: "todo",
|
||||
};
|
||||
|
||||
function Entry({ task, entry }: { task: AgentTask; entry: AgentEntry }) {
|
||||
switch (entry.type) {
|
||||
case "user":
|
||||
return (
|
||||
<div className={styles.user}>
|
||||
{entry.text}
|
||||
{entry.attachments.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
{entry.attachments.map((a, i) => (
|
||||
<span key={i} className={styles.attachment}>
|
||||
<Icon name={fileIconName(a)} size={12} />
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "agent":
|
||||
return <Markdown text={entry.text} />;
|
||||
case "thought":
|
||||
return (
|
||||
<details className={styles.thought}>
|
||||
<summary>
|
||||
<T>Thinking</T>
|
||||
</summary>
|
||||
<div className={styles.thoughtText}>{entry.text}</div>
|
||||
</details>
|
||||
);
|
||||
case "tool":
|
||||
return <ToolEntry task={task} entry={entry} />;
|
||||
case "plan":
|
||||
return (
|
||||
<div className={styles.plan}>
|
||||
<div className={styles.planTitle}>
|
||||
<T>Plan</T>
|
||||
</div>
|
||||
{entry.items.map((item, i) => (
|
||||
<div key={i} className={clsx(styles.planItem, item.status === "completed" && styles.planDone)}>
|
||||
<Icon name={item.status === "completed" ? "check" : item.status === "in_progress" ? "run" : "minus"} size={12} />
|
||||
{item.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "permission":
|
||||
return <PermissionLine task={task} entry={entry} />;
|
||||
case "error":
|
||||
return (
|
||||
<div className={styles.error} role="alert">
|
||||
<Icon name="error" size={14} />
|
||||
<span>{entry.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract<AgentEntry, { type: "tool" }> }) {
|
||||
const head = (
|
||||
<>
|
||||
<Icon name={TOOL_ICONS[entry.kind] ?? "run"} size={14} />
|
||||
<span className={styles.toolTitle}>{shortTitle(task, entry.title || entry.kind)}</span>
|
||||
{entry.diffs.map((d) => (
|
||||
<button
|
||||
key={d.path}
|
||||
type="button"
|
||||
className={styles.toolDiff}
|
||||
title={d.path}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openChangeDiff(task, d.path);
|
||||
}}
|
||||
>
|
||||
<span className={styles.added}>+{d.added}</span> <span className={styles.removed}>−{d.removed}</span>
|
||||
</button>
|
||||
))}
|
||||
{(entry.status === "running" || entry.status === "pending") && <Spinner size={12} />}
|
||||
{entry.status === "failed" && <Icon name="error" size={12} />}
|
||||
</>
|
||||
);
|
||||
if (!entry.output) return <div className={styles.tool}>{head}</div>;
|
||||
return (
|
||||
<details className={styles.toolDetails}>
|
||||
<summary className={styles.tool}>{head}</summary>
|
||||
<pre className={styles.output}>{entry.output}</pre>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** Agent-written titles with the task's own folder dropped from paths. */
|
||||
const shortTitle = (task: AgentTask, title: string) => title.replaceAll(`${task.cwd}/`, "");
|
||||
|
||||
function PermissionLine({ task, entry }: { task: AgentTask; entry: Extract<AgentEntry, { type: "permission" }> }) {
|
||||
return (
|
||||
<div className={styles.permissionLine}>
|
||||
<Icon name={!entry.chosen ? "warning" : entry.chosen === "Cancelled" ? "minus" : "check"} size={13} />
|
||||
<span className={styles.permissionTitle}>{shortTitle(task, entry.title)}</span>
|
||||
<span className={styles.muted}>{entry.chosen ?? <T>Waiting for your answer</T>}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The open permission request, as an editor notification banner above the conversation. */
|
||||
function PermissionBanner({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const entry = entries?.find((e): e is Extract<AgentEntry, { type: "permission" }> => e.type === "permission" && !e.chosen);
|
||||
if (!entry) return null;
|
||||
const answer = (option: string | null) => {
|
||||
setBusy(true);
|
||||
void respond(task.id, entry.id, option).finally(() => setBusy(false));
|
||||
};
|
||||
return (
|
||||
<div className={styles.banner} role="alert" aria-label={entry.title}>
|
||||
<Icon name="warning" size={16} />
|
||||
<span className={styles.bannerText} title={entry.title}>
|
||||
{shortTitle(task, entry.title)}
|
||||
</span>
|
||||
<span className={styles.bannerSpacer} />
|
||||
{entry.options.map((o) => (
|
||||
<button key={o.id} type="button" className={styles.bannerLink} disabled={busy} onClick={() => answer(o.id)}>
|
||||
{o.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionComposer({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) {
|
||||
const project = useProject((s) => s.project);
|
||||
const composer = useAgents((s) => s.composers.get(task.id)) ?? emptyDraft();
|
||||
const [sending, setSending] = useState(false);
|
||||
const busy = isBusy(task.status);
|
||||
// ↑ recalls this conversation's messages, newest first.
|
||||
const history = useMemo(
|
||||
() => (entries ?? []).flatMap((e) => (e.type === "user" && e.text ? [e.text] : [])).reverse(),
|
||||
[entries],
|
||||
);
|
||||
const send = (now: boolean) => {
|
||||
if (!canSend(composer) || sending || task.status === "archived") return;
|
||||
setSending(true);
|
||||
sendMessage(task.id, now)
|
||||
.catch((e: unknown) => 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) }),
|
||||
);
|
||||
return (
|
||||
<div className={styles.composerArea}>
|
||||
{task.queue.length > 0 && <Queue task={task} />}
|
||||
<Composer
|
||||
id={task.id}
|
||||
placeholder={busy ? "Queue a message for when the agent is done (⌘↵ sends it now)" : "Reply to the agent"}
|
||||
root={task.cwd}
|
||||
projectRoot={project?.root ?? task.cwd}
|
||||
commands={task.commands}
|
||||
history={history}
|
||||
config={task.config}
|
||||
onConfig={setOption}
|
||||
onSubmit={send}
|
||||
disabled={task.status === "archived"}
|
||||
toolbar={<ConfigPickers config={task.config} onChange={setOption} />}
|
||||
actions={
|
||||
<>
|
||||
{busy && (
|
||||
<Button size="small" icon="stop" onClick={() => void cancelTask(task.id)}>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant={busy ? "secondary" : "primary"}
|
||||
disabled={!canSend(composer) || sending || task.status === "archived"}
|
||||
onClick={() => send(false)}
|
||||
>
|
||||
{busy ? "Queue" : "Send"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Messages waiting for the turn to end: send now, edit, or drop. */
|
||||
function Queue({ task }: { task: AgentTask }) {
|
||||
const fail = (e: unknown) => notifications().notify({ level: "error", title: "Couldn't update the queue", body: String(e) });
|
||||
return (
|
||||
<div className={styles.queue} aria-label="Queued messages">
|
||||
{task.queue.map((q) => (
|
||||
<div key={q.id} className={styles.queued}>
|
||||
<Icon name="pause" size={12} className={styles.muted} />
|
||||
<span className={styles.queuedText} title={q.prompt.text}>
|
||||
{q.prompt.text || <T>Image</T>}
|
||||
</span>
|
||||
<button type="button" className={styles.bannerLink} onClick={() => void sendQueuedNow(task.id, q.id).catch(fail)}>
|
||||
<T>Send now</T>
|
||||
</button>
|
||||
<IconButton icon="edit" title="Edit" size="small" onClick={() => void editQueued(task.id, q.id).catch(fail)} />
|
||||
<IconButton icon="close" title="Remove" size="small" onClick={() => void removeQueued(task.id, q.id).catch(fail)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const KIND_COLOR: Partial<Record<AgentChange["kind"], string>> = {
|
||||
added: "var(--vcs-added)",
|
||||
untracked: "var(--vcs-untracked)",
|
||||
modified: "var(--vcs-modified)",
|
||||
renamed: "var(--vcs-modified)",
|
||||
deleted: "var(--vcs-deleted)",
|
||||
conflicted: "var(--vcs-conflict)",
|
||||
};
|
||||
|
||||
function ChangesPane({ task, changes }: { task: AgentTask; changes: AgentChange[] | null }) {
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
const added = changes?.reduce((n, c) => n + c.added, 0) ?? 0;
|
||||
const removed = changes?.reduce((n, c) => n + c.removed, 0) ?? 0;
|
||||
return (
|
||||
<aside className={styles.changes} aria-label="Changes">
|
||||
<div className={styles.changesHeader}>
|
||||
<span className={styles.changesTitle}>
|
||||
<T>Changes</T>
|
||||
</span>
|
||||
{changes && changes.length > 0 && (
|
||||
<span className={styles.muted}>
|
||||
{changes.length} <T>{changes.length === 1 ? "file" : "files"}</T>
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.bannerSpacer} />
|
||||
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshChanges(task.id)} />
|
||||
</div>
|
||||
{task.base && (
|
||||
<div className={styles.changesSummary}>
|
||||
<T>vs</T> {task.base}
|
||||
{changes && changes.length > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
<span className={styles.added}>+{added}</span> <span className={styles.removed}>−{removed}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.changesList}>
|
||||
{changes === null ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Tree<AgentChange>
|
||||
aria-label="Changed files"
|
||||
roots={changes}
|
||||
getKey={(c) => c.path}
|
||||
getLabel={(c) => basename(c.path)}
|
||||
getChildren={() => undefined}
|
||||
isExpandable={() => false}
|
||||
selected={selected}
|
||||
onSelectionChange={(sel) => setSelected(sel)}
|
||||
onActivate={(c) => void openChangeDiff(task, c.path)}
|
||||
renderNode={(c) => (
|
||||
<span className={styles.change} title={c.relPath}>
|
||||
<Icon name={fileIconName(basename(c.path))} size={16} />
|
||||
<span className={styles.changeName} style={{ color: KIND_COLOR[c.kind] }}>
|
||||
{basename(c.path)}
|
||||
</span>
|
||||
<span className={styles.changeDir}>{relativeDir(task.cwd, c.path)}</span>
|
||||
<span className={styles.changeStat}>
|
||||
<span className={styles.added}>+{c.added}</span> <span className={styles.removed}>−{c.removed}</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
empty={
|
||||
<div className={styles.changesEmpty}>
|
||||
<T>No changes yet</T>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/** Opens the diff of `path` between the task's start and now. */
|
||||
export async function openChangeDiff(task: AgentTask, path: string) {
|
||||
const base = await call(commands.agentBaseText(task.id, path)).catch(() => null);
|
||||
const baseLabel = task.base ?? "Base";
|
||||
const right = task.isolation === "worktree" ? branchLabel(task.branch) : "Working tree";
|
||||
await editorService().openInput(
|
||||
{
|
||||
kind: "diff",
|
||||
key: `diff:agent:${task.id}:${path}`,
|
||||
path,
|
||||
data: {
|
||||
path,
|
||||
left: { type: "text", text: base ?? "", label: baseLabel },
|
||||
right: { type: "file", path, label: right },
|
||||
title: `${basename(path)} (${baseLabel} ↔ ${right})`,
|
||||
},
|
||||
},
|
||||
{ focus: true },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentConfigOption, AgentEntry, AgentStatus, AgentTask } from "@/bindings";
|
||||
import {
|
||||
applyCompletion,
|
||||
atEdgeLine,
|
||||
canSend,
|
||||
completionAt,
|
||||
imageFromDataUrl,
|
||||
mainOptions,
|
||||
matchCommands,
|
||||
mentionOf,
|
||||
modeIcon,
|
||||
nextValue,
|
||||
recall,
|
||||
toPrompt,
|
||||
usageLabel,
|
||||
} from "./composerModel";
|
||||
import { age, attentionChange, branchLabel, groupTasks, upsertEntry } from "./model";
|
||||
import { markdownHtml } from "./Markdown";
|
||||
|
||||
function task(id: string, status: AgentStatus, updatedMs: number): AgentTask {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
provider: "claude",
|
||||
model: null,
|
||||
status,
|
||||
isolation: "worktree",
|
||||
cwd: "/w",
|
||||
branch: `agent/${id}`,
|
||||
base: "main",
|
||||
baseCommit: "abc",
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
};
|
||||
}
|
||||
|
||||
const option = (category: string, values: string[], current: string | null): AgentConfigOption => ({
|
||||
id: category,
|
||||
name: category,
|
||||
description: null,
|
||||
category,
|
||||
current,
|
||||
values: values.map((value) => ({ value, name: value, description: null })),
|
||||
via: "option",
|
||||
});
|
||||
|
||||
describe("agent composer", () => {
|
||||
it("completes @files anywhere and /commands as the first word", () => {
|
||||
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)).toBeNull();
|
||||
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 });
|
||||
const cmds = [
|
||||
{ name: "init", description: "", hint: null },
|
||||
{ name: "review", description: "", hint: null },
|
||||
{ name: "pr-review", description: "", hint: null },
|
||||
];
|
||||
expect(matchCommands(cmds, "rev").map((c) => c.name)).toEqual(["review", "pr-review"]);
|
||||
});
|
||||
|
||||
it("sends only the files still mentioned", () => {
|
||||
const prompt = toPrompt({
|
||||
text: " look at @src/a.rs:3-5 and @b.rs ",
|
||||
files: [
|
||||
{ path: "/w/src/a.rs", rel: "src/a.rs" },
|
||||
{ path: "/w/b.rs", rel: "b.rs" },
|
||||
{ path: "/w/b.rs", rel: "b.rs" },
|
||||
{ path: "/w/gone.rs", rel: "gone.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");
|
||||
expect(canSend({ text: " ", files: [], images: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it("recalls history from the edge lines only", () => {
|
||||
const h = ["newest", "older"];
|
||||
expect(recall(h, -1, -1)).toBe(0);
|
||||
expect(recall(h, 0, -1)).toBe(1);
|
||||
expect(recall(h, 1, -1)).toBeNull();
|
||||
expect(recall(h, 0, 1)).toBe(-1);
|
||||
expect(recall(h, -1, 1)).toBeNull();
|
||||
expect(atEdgeLine("a\nb", 1, -1)).toBe(true);
|
||||
expect(atEdgeLine("a\nb", 3, -1)).toBe(false);
|
||||
expect(atEdgeLine("a\nb", 1, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("picks the main settings and cycles the mode", () => {
|
||||
const config = [option("fast", ["on"], null), option("mode", ["ask", "code"], "code"), option("model", ["m"], "m")];
|
||||
expect(mainOptions(config).map((o) => o.id)).toEqual(["model", "mode"]);
|
||||
expect(nextValue(config[1]!)).toBe("ask");
|
||||
expect(["default", "acceptEdits", "plan", "auto", "bypassPermissions", "auto_edit", "read-only", "x"].map(modeIcon)).toEqual([
|
||||
"lock",
|
||||
"edit",
|
||||
"todo",
|
||||
"run",
|
||||
"unlock",
|
||||
"edit",
|
||||
"eye",
|
||||
"lock",
|
||||
]);
|
||||
expect(usageLabel({ used: 24_000, size: 200_000, cost: 0.4, currency: "USD" })).toEqual({
|
||||
short: "12%",
|
||||
detail: "24k / 200k tokens · $0.40",
|
||||
});
|
||||
expect(imageFromDataUrl("data:image/png;base64,AAA", "a.png")).toEqual({ mimeType: "image/png", data: "AAA", name: "a.png" });
|
||||
expect(imageFromDataUrl("data:text/plain;base64,AAA")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("agents model", () => {
|
||||
it("groups tasks by what they need, newest first", () => {
|
||||
const groups = groupTasks([
|
||||
task("a", "ready", 1),
|
||||
task("b", "needsInput", 2),
|
||||
task("c", "running", 3),
|
||||
task("d", "starting", 4),
|
||||
task("e", "ready", 5),
|
||||
]);
|
||||
expect(groups.map((g) => [g.status, g.tasks.map((t) => t.id)])).toEqual([
|
||||
["needsInput", ["b"]],
|
||||
["running", ["d", "c"]],
|
||||
["ready", ["e", "a"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("upserts entries in place and refuses gaps", () => {
|
||||
const a: AgentEntry = { type: "agent", text: "a" };
|
||||
const b: AgentEntry = { type: "agent", text: "b" };
|
||||
expect(upsertEntry([a], 0, b)).toEqual([b]);
|
||||
expect(upsertEntry([a], 1, b)).toEqual([a, b]);
|
||||
expect(upsertEntry([a], 3, b)).toBeNull();
|
||||
});
|
||||
|
||||
it("notifies on finish, failure and questions only", () => {
|
||||
expect(attentionChange("running", "ready")).toBe("finished");
|
||||
expect(attentionChange("running", "failed")).toBe("failed");
|
||||
expect(attentionChange("running", "needsInput")).toBe("input");
|
||||
expect(attentionChange("needsInput", "running")).toBeNull();
|
||||
expect(attentionChange("ready", "archived")).toBeNull();
|
||||
expect(attentionChange(undefined, "ready")).toBeNull();
|
||||
});
|
||||
|
||||
it("formats labels", () => {
|
||||
expect(branchLabel("agent/fix-lexer")).toBe("fix-lexer");
|
||||
expect(branchLabel(null)).toBe("");
|
||||
expect(age(0, 30_000)).toBe("now");
|
||||
expect(age(0, 5 * 60_000)).toBe("5m");
|
||||
expect(age(0, 3 * 3_600_000)).toBe("3h");
|
||||
});
|
||||
|
||||
it("renders agent markdown without scripts", () => {
|
||||
const html = markdownHtml("**bold** <img src=x onerror=alert(1)><script>alert(1)</script>");
|
||||
expect(html).toContain("<strong>bold</strong>");
|
||||
expect(html).not.toContain("onerror");
|
||||
expect(html).not.toContain("<script");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/** Pure composer helpers: `@` / `/` completion, attachments, history recall, settings. */
|
||||
import type { AgentCommand, AgentConfigOption, AgentImage, AgentPrompt, AgentUsage } from "@/bindings";
|
||||
|
||||
export interface FileRef {
|
||||
/** Absolute path (in the task's directory). */
|
||||
path: string;
|
||||
/** As written after `@`. */
|
||||
rel: string;
|
||||
}
|
||||
|
||||
export interface ComposerDraft {
|
||||
text: string;
|
||||
files: FileRef[];
|
||||
images: AgentImage[];
|
||||
}
|
||||
|
||||
export const emptyDraft = (): ComposerDraft => ({ text: "", files: [], images: [] });
|
||||
|
||||
export type Completion =
|
||||
| { kind: "file"; query: string; from: number; to: number }
|
||||
| { kind: "command"; query: string; from: number; to: number };
|
||||
|
||||
/** The `@file` or leading `/command` being typed at `caret`, if any. */
|
||||
export function completionAt(text: string, caret: number): Completion | null {
|
||||
const before = text.slice(0, caret);
|
||||
const word = /(^|\s)([@/])([^\s@]*)$/.exec(before);
|
||||
if (!word) return null;
|
||||
const from = caret - word[3]!.length - 1;
|
||||
// Commands only as the message's first word.
|
||||
if (word[2] === "/") return before.slice(0, from).trim() === "" ? { kind: "command", query: word[3]!, from, to: caret } : null;
|
||||
return { kind: "file", query: word[3]!, from, to: caret };
|
||||
}
|
||||
|
||||
/** Replaces the completion with `insert` plus a space; returns the text and the caret after it. */
|
||||
export function applyCompletion(text: string, c: Completion, insert: string): { text: string; caret: number } {
|
||||
const after = text.slice(c.to);
|
||||
const glue = after.startsWith(" ") ? "" : " ";
|
||||
const next = text.slice(0, c.from) + insert + glue + after;
|
||||
return { text: next, caret: c.from + insert.length + 1 };
|
||||
}
|
||||
|
||||
/** Commands matching `query`, prefix matches first. */
|
||||
export function matchCommands(commands: readonly AgentCommand[], query: string): AgentCommand[] {
|
||||
const q = query.toLowerCase();
|
||||
const hits = commands.filter((c) => c.name.toLowerCase().includes(q));
|
||||
return hits.sort((a, b) => Number(!a.name.toLowerCase().startsWith(q)) - Number(!b.name.toLowerCase().startsWith(q)));
|
||||
}
|
||||
|
||||
/** `@rel` with a line range suffix when given ("@src/a.rs:10-20"). */
|
||||
export function mentionOf(rel: string, lines?: { from: number; to: number }): string {
|
||||
if (!lines) return `@${rel}`;
|
||||
return lines.from === lines.to ? `@${rel}:${lines.from}` : `@${rel}:${lines.from}-${lines.to}`;
|
||||
}
|
||||
|
||||
/** The prompt to send: files still mentioned in the text (each once), and the images. */
|
||||
export function toPrompt(draft: ComposerDraft): AgentPrompt {
|
||||
const seen = new Set<string>();
|
||||
const files = draft.files.filter((f) => {
|
||||
if (seen.has(f.path) || !new RegExp(`@${escapeRe(f.rel)}(?![\\w./-])|@${escapeRe(f.rel)}:`).test(draft.text)) return false;
|
||||
seen.add(f.path);
|
||||
return true;
|
||||
});
|
||||
return { text: draft.text.trim(), files: files.map((f) => f.path), images: draft.images };
|
||||
}
|
||||
|
||||
export const canSend = (d: ComposerDraft) => !!d.text.trim() || d.images.length > 0;
|
||||
|
||||
/**
|
||||
* ↑ / ↓ through sent messages (newest first). `index` −1 is the unsent draft. Returns the
|
||||
* new index, or `null` when the key should move the caret instead.
|
||||
*/
|
||||
export function recall(history: readonly string[], index: number, dir: -1 | 1): number | null {
|
||||
const next = index + (dir < 0 ? 1 : -1);
|
||||
if (next < -1 || next >= history.length) return null;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Whether ↑ / ↓ recalls history: the caret is on the first / last line. */
|
||||
export function atEdgeLine(text: string, caret: number, dir: -1 | 1): boolean {
|
||||
return dir < 0 ? !text.slice(0, caret).includes("\n") : !text.slice(caret).includes("\n");
|
||||
}
|
||||
|
||||
/** The settings shown as dropdowns: model, mode, thinking effort, in that order. */
|
||||
export function mainOptions(config: readonly AgentConfigOption[]): AgentConfigOption[] {
|
||||
const order = ["model", "mode", "thought_level"];
|
||||
return order.flatMap((c) => config.filter((o) => o.category === c).slice(0, 1));
|
||||
}
|
||||
|
||||
export const modeOption = (config: readonly AgentConfigOption[]) => config.find((o) => o.category === "mode");
|
||||
|
||||
/**
|
||||
* An icon per permission mode, by what it lets the agent do (mode ids differ per agent:
|
||||
* Claude `acceptEdits`, Gemini `auto_edit`, Codex `full-access`…). Unknown modes ask.
|
||||
*/
|
||||
export function modeIcon(mode: string): string {
|
||||
const m = mode.toLowerCase().replace(/[^a-z]/g, "");
|
||||
if (m.includes("plan") || m === "architect") return "todo";
|
||||
if (m.includes("bypass") || m === "yolo" || m.includes("fullaccess") || m === "dangerous") return "unlock";
|
||||
if (m.includes("readonly") || m === "ask" || m === "chat") return "eye";
|
||||
if (m.includes("edit") || m === "code") return "edit";
|
||||
if (m === "auto" || m === "agent") return "run";
|
||||
return "lock";
|
||||
}
|
||||
|
||||
/** Shift+Tab: the mode after the current one. */
|
||||
export function nextValue(option: AgentConfigOption, current = option.current): string | null {
|
||||
if (!option.values.length) return null;
|
||||
const i = option.values.findIndex((v) => v.value === current);
|
||||
return option.values[(i + 1) % option.values.length]!.value;
|
||||
}
|
||||
|
||||
/** "12%" of the context window and a tooltip with the numbers. */
|
||||
export function usageLabel(u: AgentUsage): { short: string; detail: string } {
|
||||
const pct = Math.min(100, Math.round((u.used / u.size) * 100));
|
||||
const k = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${Math.round(n)}`);
|
||||
const cost = u.cost != null ? ` · ${u.currency === "USD" || !u.currency ? "$" : `${u.currency} `}${u.cost.toFixed(2)}` : "";
|
||||
return { short: `${pct}%`, detail: `${k(u.used)} / ${k(u.size)} tokens${cost}` };
|
||||
}
|
||||
|
||||
/** `data:image/png;base64,AAA` → an image attachment. */
|
||||
export function imageFromDataUrl(url: string, name?: string): AgentImage | null {
|
||||
const m = /^data:([^;,]+);base64,(.*)$/s.exec(url);
|
||||
if (!m || !m[1]!.startsWith("image/")) return null;
|
||||
return { mimeType: m[1]!, data: m[2]!, name: name ?? null };
|
||||
}
|
||||
|
||||
export const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp)$/i;
|
||||
/** Larger images are refused (agents reject them anyway). */
|
||||
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
function escapeRe(s: string) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Coding agents (Claude Code, Codex, Gemini CLI over ACP, run by `ide-agent`): the
|
||||
* Agents tool window lists tasks by what they need from the user, a task opens as an
|
||||
* editor tab (conversation + changed files), and New Task is an editor page.
|
||||
*/
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import {
|
||||
registerEditorKind,
|
||||
registerMenuGroup,
|
||||
registerStatusItem,
|
||||
registerToolWindow,
|
||||
toolWindowService,
|
||||
} from "@/workbench/contributions";
|
||||
import { AgentsStatus } from "./AgentsStatus";
|
||||
import { HOME_KIND, SESSION_KIND, TOOL_WINDOW } from "./model";
|
||||
import { sendPaths, sendSelection } from "./references";
|
||||
import { connect, disconnect, openHome, useAgents } from "./store";
|
||||
|
||||
export function activate(): () => void {
|
||||
const sync = (root: string | null | undefined) => {
|
||||
disconnect();
|
||||
if (root) void connect().catch((e: unknown) => console.warn("agents unavailable", e));
|
||||
};
|
||||
sync(useProject.getState().project?.root);
|
||||
const disposers = [
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root) sync(s.project?.root);
|
||||
}),
|
||||
registerToolWindow({
|
||||
id: TOOL_WINDOW,
|
||||
title: "Agents",
|
||||
icon: "toolwindow-agents",
|
||||
anchor: "left",
|
||||
order: 30,
|
||||
component: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsView)),
|
||||
headerActions: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsHeaderActions)),
|
||||
useBadge: () => {
|
||||
const n = useAgents((s) => [...s.tasks.values()].filter((t) => t.status === "needsInput").length);
|
||||
return n || null;
|
||||
},
|
||||
}),
|
||||
registerEditorKind({
|
||||
kind: SESSION_KIND,
|
||||
component: lazyComponent(() => import("./SessionEditor").then((m) => m.SessionEditor)),
|
||||
title: (input) => {
|
||||
const data = input.data as { task: string; title?: string };
|
||||
return useAgents.getState().tasks.get(data.task)?.title ?? data.title ?? "Agent Task";
|
||||
},
|
||||
icon: () => "toolwindow-agents",
|
||||
// Tabs of tasks deleted meanwhile are closed when the tasks load (`connect`).
|
||||
restorable: true,
|
||||
}),
|
||||
registerEditorKind({
|
||||
kind: HOME_KIND,
|
||||
component: lazyComponent(() => import("./HomeEditor").then((m) => m.HomeEditor)),
|
||||
title: () => "New Agent Task",
|
||||
icon: () => "toolwindow-agents",
|
||||
restorable: true,
|
||||
}),
|
||||
registerStatusItem({ id: "agents.status", align: "right", priority: 3, title: "Agents", component: AgentsStatus }),
|
||||
registerMenuGroup({
|
||||
id: "agents.send",
|
||||
menus: ["editor", "projectView"],
|
||||
order: 80,
|
||||
items: (menu, { paths }) =>
|
||||
paths.length && !paths.some(isVirtualUri)
|
||||
? [{ kind: "action", action: "agents.sendToAgent", arg: menu === "projectView" ? paths : undefined }]
|
||||
: [],
|
||||
}),
|
||||
registerActions([
|
||||
{
|
||||
id: "agents.newTask",
|
||||
title: "New Agent Task",
|
||||
description: "Opens the New Task page; a string `arg` pre-fills the task description.",
|
||||
category: "Agents",
|
||||
icon: "plus",
|
||||
when: "projectOpen",
|
||||
keywords: ["agent", "claude", "codex", "gemini", "ai"],
|
||||
run: ({ arg }) => openHome(typeof arg === "string" ? arg : undefined),
|
||||
},
|
||||
{
|
||||
id: "agents.sendToAgent",
|
||||
title: "Send to Agent",
|
||||
description:
|
||||
"Adds the editor selection (or the file) as an @ reference to the agent message typed last; a string[] `arg` sends those paths.",
|
||||
category: "Agents",
|
||||
icon: "toolwindow-agents",
|
||||
when: "projectOpen",
|
||||
keywords: ["agent", "chat", "context", "reference", "selection"],
|
||||
run: ({ arg }) => {
|
||||
if (Array.isArray(arg)) sendPaths(arg.filter((p): p is string => typeof p === "string"));
|
||||
else sendSelection();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agents.showToolWindow",
|
||||
title: "Agents",
|
||||
category: "Agents",
|
||||
icon: "toolwindow-agents",
|
||||
when: "projectOpen",
|
||||
keywords: ["agent", "tasks"],
|
||||
run: () => toolWindowService().show(TOOL_WINDOW, true),
|
||||
},
|
||||
]),
|
||||
];
|
||||
return () => {
|
||||
disposers.reverse().forEach((d) => d());
|
||||
disconnect();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Pure helpers: task grouping, entry updates, labels. */
|
||||
import type { AgentEntry, AgentStatus, AgentTask } from "@/bindings";
|
||||
|
||||
export const SESSION_KIND = "agent-session";
|
||||
export const HOME_KIND = "agent-home";
|
||||
export const HOME_KEY = "agent:new";
|
||||
export const TOOL_WINDOW = "agents";
|
||||
|
||||
export const sessionKey = (task: string) => `agent:${task}`;
|
||||
|
||||
const PROVIDER_ICONS = new Set(["claude", "codex", "gemini", "copilot", "cursor", "opencode", "qwen", "kimi", "goose", "auggie", "kiro", "vibe"]);
|
||||
|
||||
/** The agent's logo; custom agents get the generic agents icon. */
|
||||
export const providerIcon = (provider: string) =>
|
||||
PROVIDER_ICONS.has(provider) ? `agent-${provider}` : "toolwindow-agents";
|
||||
|
||||
export interface TaskGroup {
|
||||
status: "needsInput" | "failed" | "running" | "ready" | "archived";
|
||||
label: string;
|
||||
tasks: AgentTask[];
|
||||
}
|
||||
|
||||
/** Groups in attention order: what waits for the user comes first. */
|
||||
const GROUPS: Array<[TaskGroup["status"], string, AgentStatus[]]> = [
|
||||
["needsInput", "Needs Input", ["needsInput"]],
|
||||
["failed", "Failed", ["failed"]],
|
||||
["running", "Running", ["running", "starting"]],
|
||||
["ready", "Ready to Review", ["ready"]],
|
||||
["archived", "Archived", ["archived"]],
|
||||
];
|
||||
|
||||
export function groupTasks(tasks: Iterable<AgentTask>): TaskGroup[] {
|
||||
const sorted = [...tasks].sort((a, b) => (b.updatedMs ?? 0) - (a.updatedMs ?? 0));
|
||||
return GROUPS.map(([status, label, members]) => ({
|
||||
status,
|
||||
label,
|
||||
tasks: sorted.filter((t) => members.includes(t.status)),
|
||||
})).filter((g) => g.tasks.length > 0);
|
||||
}
|
||||
|
||||
export const isBusy = (s: AgentStatus) => s === "running" || s === "starting" || s === "needsInput";
|
||||
|
||||
/** `entries` with `entry` at `index` (appended when `index` is the length; gaps wait for a reload). */
|
||||
export function upsertEntry(entries: AgentEntry[], index: number, entry: AgentEntry): AgentEntry[] | null {
|
||||
if (index > entries.length) return null;
|
||||
const next = entries.slice();
|
||||
next[index] = entry;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** "agent/fix-lexer" → "fix-lexer". */
|
||||
export const branchLabel = (branch: string | null) => (branch ?? "").replace(/^agent\//, "");
|
||||
|
||||
export function statusLabel(s: AgentStatus): string {
|
||||
switch (s) {
|
||||
case "starting":
|
||||
return "Starting";
|
||||
case "running":
|
||||
return "Running";
|
||||
case "needsInput":
|
||||
return "Needs input";
|
||||
case "ready":
|
||||
return "Ready";
|
||||
case "failed":
|
||||
return "Failed";
|
||||
case "archived":
|
||||
return "Archived";
|
||||
}
|
||||
}
|
||||
|
||||
/** "3m", "2h", "5d" since `ms`. */
|
||||
export function age(ms: number, now = Date.now()): string {
|
||||
const s = Math.max(0, Math.round((now - ms) / 1000));
|
||||
if (s < 60) return "now";
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 24) return `${h}h`;
|
||||
return `${Math.round(h / 24)}d`;
|
||||
}
|
||||
|
||||
/** The status change worth a notification, if any. */
|
||||
export function attentionChange(prev: AgentStatus | undefined, next: AgentStatus): "finished" | "failed" | "input" | null {
|
||||
if (prev === next || prev === undefined) return null;
|
||||
if (next === "needsInput") return "input";
|
||||
if (!isBusy(prev)) return null;
|
||||
if (next === "ready") return "finished";
|
||||
if (next === "failed") return "failed";
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** "Send to Agent": the editor selection or Project view files as `@` references. */
|
||||
import { activeEditor } from "@/editor/api";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import { mentionOf } from "./composerModel";
|
||||
import { addReferences } from "./store";
|
||||
|
||||
const relTo = (root: string, path: string) => (path.startsWith(`${root}/`) ? path.slice(root.length + 1) : null);
|
||||
|
||||
/** The active editor's file, with the selected lines when there is a selection. */
|
||||
export function sendSelection(): boolean {
|
||||
const handle = activeEditor();
|
||||
const root = useProject.getState().project?.root;
|
||||
if (!handle || !root || isVirtualUri(handle.ctx.path)) return false;
|
||||
const rel = relTo(root, handle.ctx.path);
|
||||
if (!rel) return false;
|
||||
const { state } = handle.view;
|
||||
const sel = state.selection.main;
|
||||
// A selection ending at a line start does not include that line.
|
||||
const end = sel.to > sel.from && state.doc.lineAt(sel.to).from === sel.to ? sel.to - 1 : sel.to;
|
||||
const lines = sel.empty ? undefined : { from: state.doc.lineAt(sel.from).number, to: state.doc.lineAt(end).number };
|
||||
addReferences([{ path: handle.ctx.path, rel, mention: mentionOf(rel, lines) }]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Files and folders from the Project view. */
|
||||
export function sendPaths(paths: readonly string[]): boolean {
|
||||
const root = useProject.getState().project?.root;
|
||||
if (!root) return false;
|
||||
const refs = paths.flatMap((path) => {
|
||||
const rel = relTo(root, path);
|
||||
return rel ? [{ path, rel, mention: mentionOf(rel) }] : [];
|
||||
});
|
||||
if (refs.length) addReferences(refs);
|
||||
return refs.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/** Agent tasks of the open project, mirrored from `ide-agent` through `agentSubscribe`. */
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import { create } from "zustand";
|
||||
import type { AgentChange, AgentEntry, AgentEvent, AgentProvider, AgentTask, Isolation } from "@/bindings";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { editorService, notifications } from "@/workbench/contributions";
|
||||
import { canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel";
|
||||
import { HOME_KEY, HOME_KIND, SESSION_KIND, attentionChange, isBusy, sessionKey, upsertEntry } from "./model";
|
||||
|
||||
/** New Task page settings (its text lives in the `HOME` composer). */
|
||||
export interface Draft {
|
||||
provider: string;
|
||||
/** Settings chosen for the next task, by option id (reset when the agent changes). */
|
||||
config: Record<string, string>;
|
||||
isolation: Isolation;
|
||||
/** `null`: the current branch. */
|
||||
base: string | null;
|
||||
}
|
||||
|
||||
/** Composer key of the New Task page; sessions use their task id. */
|
||||
export const HOME = "home";
|
||||
|
||||
interface AgentsState {
|
||||
loaded: boolean;
|
||||
tasks: Map<string, AgentTask>;
|
||||
/** Entries of the tasks opened in this window. */
|
||||
entries: Map<string, AgentEntry[]>;
|
||||
changes: Map<string, AgentChange[]>;
|
||||
providers: AgentProvider[];
|
||||
draft: Draft;
|
||||
composers: Map<string, ComposerDraft>;
|
||||
/** The composer typed in last: where "Send to Agent" adds references. */
|
||||
lastComposer: string;
|
||||
/** First messages of past tasks, newest first (New Task page ↑). */
|
||||
history: string[];
|
||||
}
|
||||
|
||||
const DRAFT_KEY = "openidea.agents.draft.v2";
|
||||
const HISTORY_KEY = "openidea.agents.history.v1";
|
||||
const HISTORY_SIZE = 50;
|
||||
|
||||
function load<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const saved = JSON.parse(globalThis.localStorage?.getItem(key) ?? "null") as T | null;
|
||||
return saved ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function save(key: string, value: unknown) {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackDraft: Draft = { provider: "claude", config: {}, isolation: "worktree", base: null };
|
||||
|
||||
export const useAgents = create<AgentsState>(() => ({
|
||||
loaded: false,
|
||||
tasks: new Map(),
|
||||
entries: new Map(),
|
||||
changes: new Map(),
|
||||
providers: [],
|
||||
draft: { ...fallbackDraft, ...load<Partial<Draft>>(DRAFT_KEY, {}) },
|
||||
composers: new Map(),
|
||||
lastComposer: HOME,
|
||||
history: load<string[]>(HISTORY_KEY, []),
|
||||
}));
|
||||
|
||||
export function updateDraft(patch: Partial<Draft>) {
|
||||
const draft = { ...useAgents.getState().draft, ...patch };
|
||||
useAgents.setState({ draft });
|
||||
save(DRAFT_KEY, draft);
|
||||
}
|
||||
|
||||
export const composerDraft = (key: string) => useAgents.getState().composers.get(key) ?? emptyDraft();
|
||||
|
||||
export function updateComposer(key: string, patch: Partial<ComposerDraft>) {
|
||||
useAgents.setState((s) => ({
|
||||
composers: new Map(s.composers).set(key, { ...(s.composers.get(key) ?? emptyDraft()), ...patch }),
|
||||
lastComposer: key,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Session tabs on screen; their tasks do not notify. */
|
||||
const visible = new Set<string>();
|
||||
export function markVisible(task: string): () => void {
|
||||
visible.add(task);
|
||||
return () => visible.delete(task);
|
||||
}
|
||||
|
||||
let generation = 0;
|
||||
|
||||
/** Subscribes this window to the project's agents (again after the project changed). */
|
||||
export async function connect(): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
const gen = ++generation;
|
||||
const channel = new Channel<AgentEvent>();
|
||||
channel.onmessage = (e) => {
|
||||
if (gen === generation) onEvent(e);
|
||||
};
|
||||
const [tasks, providers] = await Promise.all([
|
||||
call(commands.agentSubscribe(channel)),
|
||||
call(commands.agentProviders()),
|
||||
]);
|
||||
if (gen !== generation) return;
|
||||
useAgents.setState({
|
||||
loaded: true,
|
||||
tasks: new Map(tasks.map((t) => [t.id, t])),
|
||||
entries: new Map(),
|
||||
changes: new Map(),
|
||||
providers,
|
||||
});
|
||||
// Restored tabs of tasks deleted meanwhile close, like editors of deleted files.
|
||||
const editors = editorService();
|
||||
for (const t of editors.openTabs?.() ?? []) {
|
||||
const task = (t.input.data as { task?: string } | undefined)?.task;
|
||||
if (t.input.kind === SESSION_KIND && task && !useAgents.getState().tasks.has(task)) editors.closeInput?.(t.input.key);
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnect() {
|
||||
generation++;
|
||||
useAgents.setState({ loaded: false, tasks: new Map(), entries: new Map(), changes: new Map() });
|
||||
}
|
||||
|
||||
function onEvent(e: AgentEvent) {
|
||||
const s = useAgents.getState();
|
||||
switch (e.type) {
|
||||
case "task": {
|
||||
const prev = s.tasks.get(e.task.id);
|
||||
useAgents.setState({ tasks: new Map(s.tasks).set(e.task.id, e.task) });
|
||||
notifyChange(prev, e.task);
|
||||
if (prev?.status !== e.task.status && !isBusy(e.task.status)) {
|
||||
void refreshChanges(e.task.id);
|
||||
void refreshProviders();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "removed": {
|
||||
const tasks = new Map(s.tasks);
|
||||
tasks.delete(e.id);
|
||||
const composers = new Map(s.composers);
|
||||
composers.delete(e.id);
|
||||
useAgents.setState({ tasks, composers });
|
||||
editorService().closeInput?.(sessionKey(e.id));
|
||||
break;
|
||||
}
|
||||
case "entry": {
|
||||
const current = s.entries.get(e.task);
|
||||
if (!current) return;
|
||||
const next = upsertEntry(current, e.index, e.entry);
|
||||
if (next) useAgents.setState({ entries: new Map(s.entries).set(e.task, next) });
|
||||
else void loadEntries(e.task);
|
||||
if (e.entry.type === "tool" && e.entry.status === "completed" && e.entry.diffs.length) {
|
||||
scheduleChanges(e.task);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function notifyChange(prev: AgentTask | undefined, task: AgentTask) {
|
||||
const change = attentionChange(prev?.status, task.status);
|
||||
if (!change) return;
|
||||
if (visible.has(task.id) && document.hasFocus()) return;
|
||||
const body =
|
||||
change === "input" ? "Waiting for your answer" : change === "failed" ? (task.error ?? "The agent stopped") : "Finished";
|
||||
notifications().notify({
|
||||
level: change === "failed" ? "error" : "info",
|
||||
title: task.title,
|
||||
body,
|
||||
key: `agent:${task.id}`,
|
||||
group: "tools",
|
||||
sticky: change !== "finished",
|
||||
actions: [{ label: change === "finished" ? "Review" : "Open", run: () => openTask(task.id) }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadEntries(task: string): Promise<void> {
|
||||
const entries = await call(commands.agentEntries(task));
|
||||
useAgents.setState((s) => ({ entries: new Map(s.entries).set(task, entries) }));
|
||||
}
|
||||
|
||||
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) }));
|
||||
} catch {
|
||||
// The worktree is gone or not a repository: nothing to list.
|
||||
useAgents.setState((s) => ({ changes: new Map(s.changes).set(task, []) }));
|
||||
}
|
||||
}
|
||||
|
||||
const pendingChanges = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
function scheduleChanges(task: string) {
|
||||
if (pendingChanges.has(task)) return;
|
||||
pendingChanges.set(
|
||||
task,
|
||||
setTimeout(() => {
|
||||
pendingChanges.delete(task);
|
||||
void refreshChanges(task);
|
||||
}, 500),
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshProviders() {
|
||||
const providers = await call(commands.agentProviders()).catch(() => null);
|
||||
if (providers) useAgents.setState({ providers });
|
||||
}
|
||||
|
||||
export function openTask(task: string) {
|
||||
// The title rides along so a restored tab has it before the tasks load.
|
||||
const title = useAgents.getState().tasks.get(task)?.title;
|
||||
void editorService().openInput({ kind: SESSION_KIND, key: sessionKey(task), data: { task, title } }, { focus: true });
|
||||
}
|
||||
|
||||
/** Opens the New Task page; `prompt` pre-fills it. */
|
||||
export function openHome(prompt?: string) {
|
||||
if (prompt !== undefined) updateComposer(HOME, { text: prompt });
|
||||
void editorService().openInput({ kind: HOME_KIND, key: HOME_KEY, data: {} }, { focus: true });
|
||||
}
|
||||
|
||||
export async function startTask(): Promise<void> {
|
||||
const { draft } = useAgents.getState();
|
||||
const composer = composerDraft(HOME);
|
||||
if (!canSend(composer)) return;
|
||||
const task = await call(
|
||||
commands.agentCreate({
|
||||
prompt: toPrompt(composer),
|
||||
provider: draft.provider,
|
||||
config: Object.entries(draft.config).map(([id, value]) => ({ id, value })),
|
||||
isolation: draft.isolation,
|
||||
base: draft.isolation === "worktree" ? draft.base : null,
|
||||
}),
|
||||
);
|
||||
// Its events can arrive before this reply: keep the newer state they carried.
|
||||
useAgents.setState((s) => ({
|
||||
tasks: s.tasks.has(task.id) ? s.tasks : new Map(s.tasks).set(task.id, task),
|
||||
entries: new Map(s.entries).set(task.id, s.entries.get(task.id) ?? []),
|
||||
}));
|
||||
remember(composer.text.trim());
|
||||
updateComposer(HOME, emptyDraft());
|
||||
editorService().closeInput?.(HOME_KEY);
|
||||
openTask(task.id);
|
||||
}
|
||||
|
||||
function remember(text: string) {
|
||||
if (!text) return;
|
||||
const history = [text, ...useAgents.getState().history.filter((h) => h !== text)].slice(0, HISTORY_SIZE);
|
||||
useAgents.setState({ history });
|
||||
save(HISTORY_KEY, history);
|
||||
}
|
||||
|
||||
/** Sends the session composer's message: queued while the agent works, or with `now` interrupting it. */
|
||||
export async function sendMessage(task: string, now: boolean): Promise<void> {
|
||||
const composer = composerDraft(task);
|
||||
if (!canSend(composer)) return;
|
||||
await call(commands.agentPrompt(task, toPrompt(composer), now));
|
||||
updateComposer(task, emptyDraft());
|
||||
}
|
||||
|
||||
/** Takes a queued message back into the composer to edit it. */
|
||||
export async function editQueued(task: string, queued: string): Promise<void> {
|
||||
const item = await call(commands.agentUnqueue(task, queued));
|
||||
const current = composerDraft(task);
|
||||
const cwd = useAgents.getState().tasks.get(task)?.cwd ?? "";
|
||||
const files: FileRef[] = item.prompt.files.map((path) => ({
|
||||
path,
|
||||
rel: path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path,
|
||||
}));
|
||||
updateComposer(task, {
|
||||
text: current.text ? `${item.prompt.text}\n${current.text}` : item.prompt.text,
|
||||
files: [...files, ...current.files],
|
||||
images: [...item.prompt.images, ...current.images],
|
||||
});
|
||||
}
|
||||
|
||||
export const removeQueued = (task: string, queued: string) => call(commands.agentUnqueue(task, queued));
|
||||
export const sendQueuedNow = (task: string, queued: string) => call(commands.agentSendQueuedNow(task, queued));
|
||||
export const setTaskConfig = (task: string, option: string, value: string) =>
|
||||
call(commands.agentSetConfig(task, option, value));
|
||||
|
||||
/**
|
||||
* Adds `@file` references (with an optional line range) to the composer typed in last and
|
||||
* shows it: IDEA-style "send this to the chat".
|
||||
*/
|
||||
export function addReferences(refs: Array<{ path: string; rel: string; mention: string }>) {
|
||||
const s = useAgents.getState();
|
||||
const key = s.lastComposer !== HOME && s.tasks.has(s.lastComposer) ? s.lastComposer : HOME;
|
||||
const task = key === HOME ? null : s.tasks.get(key)!;
|
||||
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 mentions = refs.map((r) => r.mention).join(" ");
|
||||
const text = current.text && !/\s$/.test(current.text) ? `${current.text} ${mentions} ` : `${current.text}${mentions} `;
|
||||
updateComposer(key, { text, files: [...current.files, ...files] });
|
||||
if (task) openTask(task.id);
|
||||
else openHome();
|
||||
}
|
||||
|
||||
export const cancelTask = (task: string) => call(commands.agentCancel(task));
|
||||
export const respond = (task: string, permission: number, option: string | null) =>
|
||||
call(commands.agentRespond(task, permission, option));
|
||||
export const archiveTask = (task: string) => call(commands.agentArchive(task));
|
||||
export const deleteTask = (task: string, removeWorktree: boolean) => call(commands.agentDelete(task, removeWorktree));
|
||||
@@ -35,6 +35,7 @@ import { activate as services } from "./services";
|
||||
import { activate as github } from "./github";
|
||||
import { activate as httpClient } from "./http-client";
|
||||
import { activate as database } from "./database";
|
||||
import { activate as agents } from "./agents";
|
||||
import { activate as vim, activateSettingsPages as vimPages } from "./vim";
|
||||
|
||||
type Activate = () => void | (() => void);
|
||||
@@ -72,6 +73,7 @@ const features: Array<[string, Activate]> = [
|
||||
["github", github],
|
||||
["http-client", httpClient],
|
||||
["database", database],
|
||||
["agents", agents],
|
||||
["vim", vim],
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ToolWindowContribution } from "@/workbench/contributions";
|
||||
import type { OpenEditorTab, ToolWindowContribution } from "@/workbench/contributions";
|
||||
import {
|
||||
fromNavigationHistory,
|
||||
isDismissed,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
lineSegments,
|
||||
locationBreadcrumb,
|
||||
locationTime,
|
||||
switcherItems,
|
||||
switcherPaths,
|
||||
switcherSelection,
|
||||
switcherToolWindows,
|
||||
@@ -130,6 +131,27 @@ describe("recent files switcher", () => {
|
||||
expect(switcherPaths(["/c", "/x", "/a", "/c"], ["/a", "/b", "/c"])).toEqual(["/c", "/a", "/b"]);
|
||||
});
|
||||
|
||||
it("lists every open editor in the switcher, not just files", () => {
|
||||
const tab = (kind: string, key: string, title: string, lastActive: number, path?: string): OpenEditorTab => ({
|
||||
input: { kind, key, path },
|
||||
title,
|
||||
icon: kind === "file" ? "file" : "toolwindow-agents",
|
||||
lastActive,
|
||||
});
|
||||
const tabs = [
|
||||
tab("agent-session", "agent:1", "Fix the parser", 3),
|
||||
tab("file", "/p/src/b.rs", "b.rs", 2, "/p/src/b.rs"),
|
||||
tab("file", "/p/src/a.rs", "a.rs", 0, "/p/src/a.rs"),
|
||||
tab("diff", "diff:x", "c.rs (main ↔ x)", 0, "/p/src/c.rs"),
|
||||
];
|
||||
const items = switcherItems(["/p/src/a.rs"], tabs, "", "/p");
|
||||
expect(items.map((i) => i.name)).toEqual(["Fix the parser", "b.rs", "a.rs", "c.rs (main ↔ x)"]);
|
||||
expect(items[0]).toMatchObject({ path: "agent:1", dir: "", editor: { icon: "toolwindow-agents" } });
|
||||
expect(items[1]!.editor).toBeUndefined();
|
||||
expect(items[3]).toMatchObject({ path: "diff:x", dir: "src" });
|
||||
expect(switcherItems([], tabs, "parser", "/p").map((i) => i.name)).toEqual(["Fix the parser"]);
|
||||
});
|
||||
|
||||
it("moves the switcher selection past the end of the files into the tool windows", () => {
|
||||
expect(switcherSelection(1, 0, 3, 2)).toEqual({ column: "files", index: 1 });
|
||||
expect(switcherSelection(1, 2, 3, 2)).toEqual({ column: "tools", index: 0 });
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
toolWindowService,
|
||||
useContributions,
|
||||
useFileDecoration,
|
||||
type OpenEditorTab,
|
||||
type ToolWindowContribution,
|
||||
} from "@/workbench/contributions";
|
||||
import { highlightTokens, languageForPath, type CodeToken } from "@/editor/lsp/markdown";
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
lineSegments,
|
||||
locationBreadcrumb,
|
||||
locationTime,
|
||||
switcherItems,
|
||||
switcherPaths,
|
||||
switcherSelection,
|
||||
switcherToolWindows,
|
||||
@@ -59,13 +61,20 @@ function RecentFilesDialog() {
|
||||
const root = useProject((s) => s.project?.root ?? null);
|
||||
const toolWindows = useContributions((s) => s.toolWindows);
|
||||
const [paths, setPaths] = useState<string[] | null>(null);
|
||||
// Switcher: the open editor tabs (files and others), when the editor service lists them.
|
||||
const [tabs] = useState<OpenEditorTab[] | null>(() => (switcher ? (maybeEditorService()?.openTabs?.() ?? null) : null));
|
||||
const [removed, setRemoved] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [query, setQuery] = useState("");
|
||||
const [column, setColumn] = useState<"files" | "tools">("files");
|
||||
const [fileIndex, setFileIndex] = useState<number | null>(null);
|
||||
const [toolIndex, setToolIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const activePath = useMemo(() => maybeEditorService()?.activePath() ?? null, []);
|
||||
// The current editor: a file path, or the key of a non-file editor (Switcher lists those too).
|
||||
const activePath = useMemo(() => {
|
||||
const top = tabs?.[0];
|
||||
if (top && top.lastActive > 0 && top.input.kind !== "file" && top.input.kind !== "virtual") return top.input.key;
|
||||
return maybeEditorService()?.activePath() ?? null;
|
||||
}, [tabs]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -83,12 +92,14 @@ function RecentFilesDialog() {
|
||||
|
||||
const files = useMemo(
|
||||
() =>
|
||||
filterRecentFiles(
|
||||
(paths ?? []).filter((p) => !removed.has(p)),
|
||||
query,
|
||||
{ root, edited, editedOnly },
|
||||
),
|
||||
[paths, removed, query, root, edited, editedOnly],
|
||||
tabs
|
||||
? switcherItems(paths ?? [], tabs, query, root).filter((f) => !removed.has(f.path))
|
||||
: filterRecentFiles(
|
||||
(paths ?? []).filter((p) => !removed.has(p)),
|
||||
query,
|
||||
{ root, edited, editedOnly },
|
||||
),
|
||||
[paths, tabs, removed, query, root, edited, editedOnly],
|
||||
);
|
||||
const tools = useMemo(() => switcherToolWindows(toolWindows.values(), !!root, query), [toolWindows, root, query]);
|
||||
const fIndex = files.length
|
||||
@@ -98,7 +109,13 @@ function RecentFilesDialog() {
|
||||
const effectiveColumn = column === "files" && !files.length && tools.length ? "tools" : column;
|
||||
|
||||
const openFile = (item: RecentFileItem | undefined, side = false) => {
|
||||
if (item) afterClose(() => void openLocation(item.path, null, { side }));
|
||||
if (!item) return;
|
||||
const editor = item.editor;
|
||||
afterClose(() =>
|
||||
editor
|
||||
? void maybeEditorService()?.openInput(editor.input, { side, focus: true })
|
||||
: void openLocation(item.path, null, { side }),
|
||||
);
|
||||
};
|
||||
|
||||
const openTool = (tw: ToolWindowContribution | undefined) => {
|
||||
@@ -114,7 +131,8 @@ function RecentFilesDialog() {
|
||||
}
|
||||
const item = files[fIndex];
|
||||
if (!item) return;
|
||||
maybeEditorService()?.closePath?.(item.path);
|
||||
if (item.editor) maybeEditorService()?.closeInput?.(item.path);
|
||||
else maybeEditorService()?.closePath?.(item.path);
|
||||
if (!switcher) void call(commands.removeRecent(item.path)).catch(() => {});
|
||||
setRemoved((r) => new Set(r).add(item.path));
|
||||
setFileIndex(fIndex);
|
||||
@@ -197,7 +215,7 @@ function RecentFilesDialog() {
|
||||
const recentShortcut = shortcutLabel("navigate.recentFiles");
|
||||
const locationsShortcut = shortcutLabel("navigate.recentLocations");
|
||||
const title = switcher ? "Switcher" : editedOnly ? "Recently Edited Files" : "Recent Files";
|
||||
const footerPath = selected?.path ?? (selectedTool ? "" : (root ?? ""));
|
||||
const footerPath = (selected?.editor ? selected.rel : selected?.path) ?? (selectedTool ? "" : (root ?? ""));
|
||||
return (
|
||||
<Dialog open onClose={hideRecentPopup} width={720} aria-label={title} className={styles.dialog}>
|
||||
<header className={styles.header}>
|
||||
@@ -320,10 +338,10 @@ function ToolRow({ tw, indices }: { tw: ToolWindowContribution; indices: number[
|
||||
}
|
||||
|
||||
function RecentFileRow({ item, current }: { item: RecentFileItem; current: boolean }) {
|
||||
const deco = useFileDecoration(item.path);
|
||||
const deco = useFileDecoration(item.editor ? "" : item.path);
|
||||
return (
|
||||
<>
|
||||
<Icon name={fileIconName(item.name)} size={16} className={styles.icon} />
|
||||
<Icon name={item.editor?.icon ?? fileIconName(item.name)} size={16} className={styles.icon} />
|
||||
<span
|
||||
className={clsx(styles.name, current && styles.current)}
|
||||
style={deco.color ? { color: deco.color } : undefined}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ToolWindowContribution } from "@/workbench/contributions";
|
||||
import type { EditorInput, OpenEditorTab, ToolWindowContribution } from "@/workbench/contributions";
|
||||
import type { OutlineItem } from "@/platform/ipc";
|
||||
import { formatTimestamp } from "@/platform/dateFormat";
|
||||
import type { CodeToken } from "@/editor/lsp/markdown";
|
||||
@@ -13,6 +13,8 @@ export interface RecentFileItem {
|
||||
name: string;
|
||||
dir: string;
|
||||
indices: number[];
|
||||
/** Switcher: an open editor that is not a file (agent session, diff…); `path` is its input key. */
|
||||
editor?: { input: EditorInput; icon: string };
|
||||
}
|
||||
|
||||
export function filterRecentFiles(
|
||||
@@ -55,6 +57,54 @@ export function switcherPaths(recent: readonly string[], open: readonly string[]
|
||||
return out;
|
||||
}
|
||||
|
||||
const isFileTab = (t: OpenEditorTab) => !!t.input.path && (t.input.kind === "file" || t.input.kind === "virtual");
|
||||
|
||||
/**
|
||||
* Switcher (⌃⇥) rows: every open editor, like IDEA (whose editors are all files). Tabs active
|
||||
* this session come first, most recent first; then the other open files in recent-files
|
||||
* order; then the other editors in tab order.
|
||||
*/
|
||||
export function switcherItems(
|
||||
recent: readonly string[],
|
||||
tabs: readonly OpenEditorTab[],
|
||||
query: string,
|
||||
root: string | null,
|
||||
): RecentFileItem[] {
|
||||
const fileTabs = new Map(tabs.filter(isFileTab).map((t) => [t.input.path!, t]));
|
||||
const ordered = [
|
||||
...tabs.filter((t) => t.lastActive > 0),
|
||||
...switcherPaths(recent, [...fileTabs.keys()]).flatMap((p) => {
|
||||
const t = fileTabs.get(p)!;
|
||||
return t.lastActive > 0 ? [] : [t];
|
||||
}),
|
||||
...tabs.filter((t) => t.lastActive === 0 && !isFileTab(t)),
|
||||
];
|
||||
const out: RecentFileItem[] = [];
|
||||
for (const t of ordered) {
|
||||
if (isFileTab(t)) {
|
||||
out.push(...filterRecentFiles([t.input.path!], query, { root }));
|
||||
continue;
|
||||
}
|
||||
let indices: number[] = [];
|
||||
if (query.trim()) {
|
||||
const hit = matchText(t.title, query);
|
||||
if (!hit) continue;
|
||||
indices = hit.indices;
|
||||
}
|
||||
const path = t.input.path;
|
||||
const rel = path && root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path;
|
||||
out.push({
|
||||
path: t.input.key,
|
||||
rel: rel ?? t.title,
|
||||
name: t.title,
|
||||
dir: rel ? relDir(rel) : "",
|
||||
indices,
|
||||
editor: { input: t.input, icon: t.icon },
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switcher selection `step` rows away from file row `base`: moving past the end
|
||||
* of one column continues in the other (files, then tool windows), like IDEA.
|
||||
|
||||
@@ -298,6 +298,9 @@ export const intellijMacos: Record<string, string[]> = {
|
||||
"http.runAll": [],
|
||||
"db.execute": ["Meta+Enter"],
|
||||
"db.showToolWindow": [],
|
||||
"agents.newTask": ["Meta+Shift+I"],
|
||||
"agents.showToolWindow": [],
|
||||
"agents.sendToAgent": [],
|
||||
// Database tool window (when the tree has focus; `db.openConsole` anywhere).
|
||||
"db.openConsole": ["Meta+Shift+L"],
|
||||
"db.newDataSource": [],
|
||||
|
||||
@@ -295,6 +295,9 @@ export const intellijWindows: Record<string, string[]> = {
|
||||
"http.runAll": [],
|
||||
"db.execute": ["Ctrl+Enter"],
|
||||
"db.showToolWindow": [],
|
||||
"agents.newTask": ["Ctrl+Alt+Shift+I"],
|
||||
"agents.showToolWindow": [],
|
||||
"agents.sendToAgent": [],
|
||||
// Database tool window (when the tree has focus; `db.openConsole` anywhere).
|
||||
"db.openConsole": ["Ctrl+Shift+Q"],
|
||||
"db.newDataSource": [],
|
||||
|
||||
@@ -322,6 +322,10 @@ export const vscodeMacos: Record<string, string[]> = {
|
||||
"find.popup.previousResult": ["Meta+Shift+G"],
|
||||
"find.popup.openResult": [],
|
||||
"find.popup.recentSearches": ["Alt+Down"],
|
||||
// Agents (VS Code's chat: ⇧⌘I)
|
||||
"agents.newTask": ["Meta+Shift+I"],
|
||||
"agents.showToolWindow": [],
|
||||
"agents.sendToAgent": [],
|
||||
// Search fields (find bar, Find in Files): last, so they win over editor keys while one is focused.
|
||||
"find.field.newLine": ["Meta+Shift+Enter"],
|
||||
"find.field.recentSearches": ["Alt+Down"],
|
||||
|
||||
@@ -59,4 +59,8 @@ export const vscodeWindows: Record<string, string[]> = {
|
||||
"markdown.editorOnly": [],
|
||||
"markdown.editorAndPreview": ["Ctrl+K V"],
|
||||
"markdown.previewOnly": ["Ctrl+Shift+V"],
|
||||
// Agents (VS Code's chat: Ctrl+Shift+I)
|
||||
"agents.newTask": ["Ctrl+Shift+I"],
|
||||
"agents.showToolWindow": [],
|
||||
"agents.sendToAgent": [],
|
||||
};
|
||||
|
||||
@@ -3259,6 +3259,61 @@
|
||||
"inserts a template from the Includes or Code tab.": "插入“包含”或“代码”标签页中的模板。",
|
||||
"File templates insert this with {code}, indented like the directive; a directive line whose include is empty is dropped. The file template variables apply.": "文件模板通过 {code} 插入此内容,缩进与该指令一致;所含内容为空的指令行将被删除。文件模板变量同样适用。",
|
||||
"Inserted where a file template says {code}.": "插入到文件模板中写有 {code} 的位置。",
|
||||
"Agents": "智能体",
|
||||
"New Agent Task": "新建 Agent 任务",
|
||||
"No agent tasks yet": "还没有 Agent 任务",
|
||||
"New Task…": "新建任务…",
|
||||
"Needs Input": "等待输入",
|
||||
"Ready to Review": "待审阅",
|
||||
"Archived": "已归档",
|
||||
"Archive": "归档",
|
||||
"Archive Task": "归档任务",
|
||||
"Delete Agent Task": "删除 Agent 任务",
|
||||
"Also remove its worktree folder": "同时删除它的 worktree 目录",
|
||||
"needs input": "等待输入",
|
||||
"running": "正在运行",
|
||||
"This agent task no longer exists": "这个 Agent 任务已不存在",
|
||||
"Local checkout": "当前工作区",
|
||||
"Needs input": "等待输入",
|
||||
"Ready": "就绪",
|
||||
"Starting the agent…": "正在启动 agent…",
|
||||
"Working…": "处理中…",
|
||||
"Thinking": "思考过程",
|
||||
"Plan": "计划",
|
||||
"Message": "消息",
|
||||
"The agent is working. You can type your next message.": "Agent 正在处理,可以先写下一条消息。",
|
||||
"Reply to the agent": "回复 agent",
|
||||
"Send": "发送",
|
||||
"vs": "对比",
|
||||
"file": "个文件",
|
||||
"files": "个文件",
|
||||
"No changes yet": "还没有改动",
|
||||
"What should the agent do?": "让 agent 做点什么?",
|
||||
"Describe the task": "描述任务",
|
||||
"New worktree": "新 worktree",
|
||||
"Starting…": "正在启动…",
|
||||
"Waiting for your answer": "等待你的回答",
|
||||
"The agent stopped": "Agent 已停止",
|
||||
"Not installed": "未安装",
|
||||
"{command} was not found on PATH": "在 PATH 中找不到 {command}",
|
||||
"Agent tasks": "Agent 任务",
|
||||
"Describe a task for the agent. @ references a file, / runs a command": "描述要交给 agent 的任务。@ 引用文件,/ 运行命令",
|
||||
"away from your changes": "不影响你的改动",
|
||||
"edits your files directly": "直接修改你的文件",
|
||||
"from": "基于",
|
||||
"Models and modes appear after this agent's first task.": "这个 agent 跑完第一个任务后会显示模型和模式。",
|
||||
"Queue a message for when the agent is done (⌘↵ sends it now)": "排队一条消息,agent 做完后发送(⌘↵ 立即发送)",
|
||||
"Couldn't send the message": "消息发送失败",
|
||||
"Couldn't change the setting": "设置修改失败",
|
||||
"Couldn't update the queue": "队列更新失败",
|
||||
"Queued messages": "排队中的消息",
|
||||
"Send now": "立即发送",
|
||||
"Queue": "排队",
|
||||
"Reference a File (@)": "引用文件 (@)",
|
||||
"Attach Image…": "附加图片…",
|
||||
"Remove Image": "移除图片",
|
||||
"Image too large": "图片太大",
|
||||
"Send to Agent": "发送给 Agent",
|
||||
"Enable Vim emulation": "启用 Vim 模拟",
|
||||
"Configuration file": "配置文件",
|
||||
"mappings": "个映射",
|
||||
|
||||
@@ -85,6 +85,11 @@
|
||||
padding: 0 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Short details (a count, a command) widen the popup; long ones (paths, URLs) are cut. */
|
||||
.sizerDetail {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.optionLabel {
|
||||
flex: 1 0 auto;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -134,6 +134,7 @@ export function Select<V extends string = string>({
|
||||
<span className={styles.check} />
|
||||
{o.icon && <Icon name={o.icon} size={14} />}
|
||||
<span>{tx(o.label)}</span>
|
||||
{o.detail && <span className={styles.sizerDetail}>{tx(o.detail)}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -160,6 +160,20 @@ const ICONS = {
|
||||
"toolwindow-duplicates": "toolwindows/toolWindowDuplicates",
|
||||
"toolwindow-changes": "toolwindows/changes",
|
||||
"toolwindow-database": "openidea/toolwindows/database",
|
||||
"toolwindow-agents": "openidea/toolwindows/agents",
|
||||
// Coding agents (logos via Paseo)
|
||||
"agent-claude": "openidea/agents/claude",
|
||||
"agent-codex": "openidea/agents/codex",
|
||||
"agent-gemini": "openidea/agents/gemini",
|
||||
"agent-copilot": "openidea/agents/copilot",
|
||||
"agent-cursor": "openidea/agents/cursor",
|
||||
"agent-opencode": "openidea/agents/opencode",
|
||||
"agent-qwen": "openidea/agents/qwen",
|
||||
"agent-kimi": "openidea/agents/kimi",
|
||||
"agent-goose": "openidea/agents/goose",
|
||||
"agent-auggie": "openidea/agents/auggie",
|
||||
"agent-kiro": "openidea/agents/kiro",
|
||||
"agent-vibe": "openidea/agents/vibe",
|
||||
"toolwindow-maven": "toolwindows/maven",
|
||||
"toolwindow-gradle": "toolwindows/gradle",
|
||||
"toolwindow-build": "toolwindows/build",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
|
||||
<path fill="#1F2023" d="M9.972 13.193h2.577q.187 0 .277-.09t.091-.294V10.47q0-.324.133-.59.135-.27.36-.424a1 1 0 0 1 .099-.06 1 1 0 0 1-.1-.058 1.1 1.1 0 0 1-.359-.424 1.3 1.3 0 0 1-.133-.59v-2.33q0-.202-.091-.293t-.277-.089H9.972a.38.38 0 0 1-.294-.124.4.4 0 0 1-.108-.281q.002-.176.131-.293a.46.46 0 0 1 .313-.111h2.775c.36 0 .642.097.836.288q.293.29.294.832V8.24q0 .39.152.562.148.168.555.178a.4.4 0 0 1 .27.128.41.41 0 0 1 .104.287.44.44 0 0 1-.1.282.34.34 0 0 1-.277.132c-.266.006-.452.066-.55.177q-.151.174-.152.573v2.318c0 .235-.043.44-.128.607a.85.85 0 0 1-.389.387 1.3 1.3 0 0 1-.534.124V14h-2.854a.45.45 0 0 1-.315-.118.38.38 0 0 1-.129-.286q0-.164.109-.282a.38.38 0 0 1 .293-.123zM1.103 9.108a.4.4 0 0 1 .276-.128q.4-.008.55-.177.15-.173.151-.562V5.923q.002-.543.294-.832c.195-.191.477-.288.836-.288h2.775q.188 0 .313.111a.37.37 0 0 1 .131.293.4.4 0 0 1-.108.281.38.38 0 0 1-.293.123H3.45q-.186 0-.277.09t-.092.292v2.33c0 .213-.044.413-.133.59a1.1 1.1 0 0 1-.359.424 1 1 0 0 1-.1.059 1 1 0 0 1 .1.059q.225.156.359.423t.133.59v2.34q0 .203.092.293.088.09.277.089h2.577a.38.38 0 0 1 .294.123q.108.12.108.281a.38.38 0 0 1-.13.286.45.45 0 0 1-.314.118l-2.775-.003a1.4 1.4 0 0 1-.613-.126.87.87 0 0 1-.388-.387 1.34 1.34 0 0 1-.129-.608v-2.318q0-.4-.151-.572-.151-.168-.55-.177a.35.35 0 0 1-.278-.132.42.42 0 0 1-.102-.28q0-.173.103-.287"/>
|
||||
<path fill="#1F2023" d="M5.437 10.36a.986.986 0 0 1-.994-.975c0-.538.446-.976.994-.976s.994.438.994.976a.986.986 0 0 1-.994.976M10.562 8.41c.548 0 .994.437.994.975a.986.986 0 0 1-.994.976.986.986 0 0 1-.994-.976c0-.538.446-.976.994-.976M8.74 2c.37 0 .446.15.446.275v.034q0 .034-.009.201-.01.162-.017.549-.018.378-.044 1.06c0 .106-.065.233-.375.233-.311 0-.376-.126-.376-.23l-.07-1.835v-.003c0-.13.078-.284.446-.284M7.256 2c.369 0 .446.15.446.275v.034q0 .034-.01.201-.007.162-.017.549-.017.378-.043 1.06c0 .106-.065.233-.376.233-.31 0-.375-.126-.375-.23l-.07-1.835v-.003C6.81 2.154 6.887 2 7.256 2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
|
||||
<path fill="#DFE1E5" d="M9.972 13.193h2.577q.187 0 .277-.09t.091-.294V10.47q0-.324.133-.59.135-.27.36-.424a1 1 0 0 1 .099-.06 1 1 0 0 1-.1-.058 1.1 1.1 0 0 1-.359-.424 1.3 1.3 0 0 1-.133-.59v-2.33q0-.202-.091-.293t-.277-.089H9.972a.38.38 0 0 1-.294-.124.4.4 0 0 1-.108-.281q.002-.176.131-.293a.46.46 0 0 1 .313-.111h2.775c.36 0 .642.097.836.288q.293.29.294.832V8.24q0 .39.152.562.148.168.555.178a.4.4 0 0 1 .27.128.41.41 0 0 1 .104.287.44.44 0 0 1-.1.282.34.34 0 0 1-.277.132c-.266.006-.452.066-.55.177q-.151.174-.152.573v2.318c0 .235-.043.44-.128.607a.85.85 0 0 1-.389.387 1.3 1.3 0 0 1-.534.124V14h-2.854a.45.45 0 0 1-.315-.118.38.38 0 0 1-.129-.286q0-.164.109-.282a.38.38 0 0 1 .293-.123zM1.103 9.108a.4.4 0 0 1 .276-.128q.4-.008.55-.177.15-.173.151-.562V5.923q.002-.543.294-.832c.195-.191.477-.288.836-.288h2.775q.188 0 .313.111a.37.37 0 0 1 .131.293.4.4 0 0 1-.108.281.38.38 0 0 1-.293.123H3.45q-.186 0-.277.09t-.092.292v2.33c0 .213-.044.413-.133.59a1.1 1.1 0 0 1-.359.424 1 1 0 0 1-.1.059 1 1 0 0 1 .1.059q.225.156.359.423t.133.59v2.34q0 .203.092.293.088.09.277.089h2.577a.38.38 0 0 1 .294.123q.108.12.108.281a.38.38 0 0 1-.13.286.45.45 0 0 1-.314.118l-2.775-.003a1.4 1.4 0 0 1-.613-.126.87.87 0 0 1-.388-.387 1.34 1.34 0 0 1-.129-.608v-2.318q0-.4-.151-.572-.151-.168-.55-.177a.35.35 0 0 1-.278-.132.42.42 0 0 1-.102-.28q0-.173.103-.287"/>
|
||||
<path fill="#DFE1E5" d="M5.437 10.36a.986.986 0 0 1-.994-.975c0-.538.446-.976.994-.976s.994.438.994.976a.986.986 0 0 1-.994.976M10.562 8.41c.548 0 .994.437.994.975a.986.986 0 0 1-.994.976.986.986 0 0 1-.994-.976c0-.538.446-.976.994-.976M8.74 2c.37 0 .446.15.446.275v.034q0 .034-.009.201-.01.162-.017.549-.018.378-.044 1.06c0 .106-.065.233-.375.233-.311 0-.376-.126-.376-.23l-.07-1.835v-.003c0-.13.078-.284.446-.284M7.256 2c.369 0 .446.15.446.275v.034q0 .034-.01.201-.007.162-.017.549-.017.378-.043 1.06c0 .106-.065.233-.376.233-.31 0-.375-.126-.375-.23l-.07-1.835v-.003C6.81 2.154 6.887 2 7.256 2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 1200 1200">
|
||||
<path fill="#D97757" d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="#1F2023" d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="#DFE1E5" d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.99816 14.2779C12.0678 14.2779 14.9997 11.6274 14.9997 10.9574V9.3189C14.939 8.77306 14.4165 7.82349 13.6074 7.48636C13.5959 7.42498 13.5859 7.36153 13.5756 7.29542C13.5506 7.13548 13.5231 6.95977 13.4649 6.75954C13.6409 6.31508 13.6873 5.81187 13.6873 5.31135C13.6873 4.54947 13.5757 3.76321 13.0811 3.13712C12.5745 2.4959 11.7737 2.15372 10.6972 2.03411C9.64215 1.91688 8.71828 2.06409 8.12157 2.70342C8.07827 2.74981 8.0345 2.79812 7.99655 2.84746C7.95861 2.79812 7.91805 2.74981 7.87475 2.70342C7.27804 2.06409 6.35416 1.91688 5.29908 2.03411C4.22263 2.15372 3.42181 2.4959 2.91521 3.13712C2.42057 3.76321 2.30905 4.54947 2.30905 5.31135C2.30905 5.81187 2.35539 6.31508 2.53142 6.75954C2.47323 6.95977 2.44574 7.13542 2.42072 7.29536C2.41037 7.3615 2.40044 7.42496 2.38893 7.48636C1.57983 7.82349 1.05728 8.77306 0.996582 9.3189V10.9574C0.996582 11.6274 3.92849 14.2779 7.99816 14.2779ZM10.5523 3.33859C9.63863 3.23707 9.25001 3.41798 9.0811 3.59896C8.90273 3.79007 8.76738 4.19254 8.86971 5.0112C8.94909 5.64627 9.13423 6.08757 9.41018 6.36929C9.67188 6.63645 10.0966 6.8426 10.8435 6.8426C11.6506 6.8426 11.9631 6.66867 12.1051 6.51046C12.2619 6.33577 12.3748 6.00462 12.3748 5.31135C12.3748 4.64141 12.2675 4.22455 12.0513 3.95077C11.8469 3.69214 11.4446 3.43773 10.5523 3.33859ZM6.91639 3.59896C6.74748 3.41798 6.35886 3.23707 5.44518 3.33859C4.55289 3.43773 4.15058 3.69214 3.94624 3.95077C3.72994 4.22455 3.62271 4.64141 3.62271 5.31135C3.62271 6.00462 3.73563 6.33577 3.89239 6.51046C4.03436 6.66867 4.34693 6.8426 5.15396 6.8426C5.90092 6.8426 6.32561 6.63645 6.58731 6.36929C6.86326 6.08757 7.0484 5.64627 7.12778 5.0112C7.23011 4.19254 7.09476 3.79007 6.91639 3.59896ZM7.99818 12.9788C9.99307 12.9788 12.0088 12.0076 12.3748 11.7248V8.00133L12.3546 7.90026C11.9257 8.08369 11.4142 8.1551 10.8435 8.1551C9.84049 8.1551 9.04148 7.86851 8.47257 7.28773C8.28092 7.09208 8.12536 6.87308 7.99978 6.6378C7.8742 6.87308 7.71544 7.09208 7.52378 7.28773C6.95488 7.86851 6.15587 8.1551 5.15283 8.1551C4.58212 8.1551 4.07068 8.08369 3.6418 7.90026L3.62158 8.00133V11.7248C3.98755 12.0076 6.00329 12.9788 7.99818 12.9788Z" fill="#1F2023"/>
|
||||
<path d="M6.46854 9.0301C6.83097 9.0301 7.12479 9.32391 7.12479 9.68635V10.9988C7.12479 11.3613 6.83097 11.6551 6.46854 11.6551C6.1061 11.6551 5.81229 11.3613 5.81229 10.9988V9.68635C5.81229 9.32391 6.1061 9.0301 6.46854 9.0301Z" fill="#1F2023"/>
|
||||
<path d="M10.1873 9.68635C10.1873 9.32391 9.89347 9.0301 9.53104 9.0301C9.1686 9.0301 8.87479 9.32391 8.87479 9.68635V10.9988C8.87479 11.3613 9.1686 11.6551 9.53104 11.6551C9.89347 11.6551 10.1873 11.3613 10.1873 10.9988V9.68635Z" fill="#1F2023"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.99816 14.2779C12.0678 14.2779 14.9997 11.6274 14.9997 10.9574V9.3189C14.939 8.77306 14.4165 7.82349 13.6074 7.48636C13.5959 7.42498 13.5859 7.36153 13.5756 7.29542C13.5506 7.13548 13.5231 6.95977 13.4649 6.75954C13.6409 6.31508 13.6873 5.81187 13.6873 5.31135C13.6873 4.54947 13.5757 3.76321 13.0811 3.13712C12.5745 2.4959 11.7737 2.15372 10.6972 2.03411C9.64215 1.91688 8.71828 2.06409 8.12157 2.70342C8.07827 2.74981 8.0345 2.79812 7.99655 2.84746C7.95861 2.79812 7.91805 2.74981 7.87475 2.70342C7.27804 2.06409 6.35416 1.91688 5.29908 2.03411C4.22263 2.15372 3.42181 2.4959 2.91521 3.13712C2.42057 3.76321 2.30905 4.54947 2.30905 5.31135C2.30905 5.81187 2.35539 6.31508 2.53142 6.75954C2.47323 6.95977 2.44574 7.13542 2.42072 7.29536C2.41037 7.3615 2.40044 7.42496 2.38893 7.48636C1.57983 7.82349 1.05728 8.77306 0.996582 9.3189V10.9574C0.996582 11.6274 3.92849 14.2779 7.99816 14.2779ZM10.5523 3.33859C9.63863 3.23707 9.25001 3.41798 9.0811 3.59896C8.90273 3.79007 8.76738 4.19254 8.86971 5.0112C8.94909 5.64627 9.13423 6.08757 9.41018 6.36929C9.67188 6.63645 10.0966 6.8426 10.8435 6.8426C11.6506 6.8426 11.9631 6.66867 12.1051 6.51046C12.2619 6.33577 12.3748 6.00462 12.3748 5.31135C12.3748 4.64141 12.2675 4.22455 12.0513 3.95077C11.8469 3.69214 11.4446 3.43773 10.5523 3.33859ZM6.91639 3.59896C6.74748 3.41798 6.35886 3.23707 5.44518 3.33859C4.55289 3.43773 4.15058 3.69214 3.94624 3.95077C3.72994 4.22455 3.62271 4.64141 3.62271 5.31135C3.62271 6.00462 3.73563 6.33577 3.89239 6.51046C4.03436 6.66867 4.34693 6.8426 5.15396 6.8426C5.90092 6.8426 6.32561 6.63645 6.58731 6.36929C6.86326 6.08757 7.0484 5.64627 7.12778 5.0112C7.23011 4.19254 7.09476 3.79007 6.91639 3.59896ZM7.99818 12.9788C9.99307 12.9788 12.0088 12.0076 12.3748 11.7248V8.00133L12.3546 7.90026C11.9257 8.08369 11.4142 8.1551 10.8435 8.1551C9.84049 8.1551 9.04148 7.86851 8.47257 7.28773C8.28092 7.09208 8.12536 6.87308 7.99978 6.6378C7.8742 6.87308 7.71544 7.09208 7.52378 7.28773C6.95488 7.86851 6.15587 8.1551 5.15283 8.1551C4.58212 8.1551 4.07068 8.08369 3.6418 7.90026L3.62158 8.00133V11.7248C3.98755 12.0076 6.00329 12.9788 7.99818 12.9788Z" fill="#DFE1E5"/>
|
||||
<path d="M6.46854 9.0301C6.83097 9.0301 7.12479 9.32391 7.12479 9.68635V10.9988C7.12479 11.3613 6.83097 11.6551 6.46854 11.6551C6.1061 11.6551 5.81229 11.3613 5.81229 10.9988V9.68635C5.81229 9.32391 6.1061 9.0301 6.46854 9.0301Z" fill="#DFE1E5"/>
|
||||
<path d="M10.1873 9.68635C10.1873 9.32391 9.89347 9.0301 9.53104 9.0301C9.1686 9.0301 8.87479 9.32391 8.87479 9.68635V10.9988C8.87479 11.3613 9.1686 11.6551 9.53104 11.6551C9.89347 11.6551 10.1873 11.3613 10.1873 10.9988V9.68635Z" fill="#DFE1E5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 466.73 532.09">
|
||||
<path fill="#1F2023" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 568 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 466.73 532.09">
|
||||
<path fill="#DFE1E5" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 568 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<defs><linearGradient id="g" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4285F4"/><stop offset="0.55" stop-color="#9B72CB"/><stop offset="1" stop-color="#D96570"/></linearGradient></defs><path fill="url(#g)" d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 639 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="#1F2023" d="M20.9093 19.3861L19.5185 18.2413C18.7624 17.619 18.1189 16.8713 17.6157 16.0313C16.9205 14.8706 15.9599 13.8912 14.8133 13.1735L14.2533 12.8475C14.0614 12.7141 13.9276 12.5062 13.9086 12.2716C13.8963 12.1204 13.9326 11.9852 14.0171 11.8662C14.3087 11.4553 15.896 9.74698 16.1722 9.51845C16.528 9.22442 16.9243 8.97987 17.2921 8.69986C17.3443 8.66 17.3968 8.62035 17.4485 8.57989C17.4503 8.57808 17.4529 8.57668 17.4545 8.57508C17.5725 8.48195 17.6838 8.383 17.7724 8.26563C18.2036 7.76631 18.195 7.3443 18.195 7.3443C18.195 7.3443 18.1954 7.3439 18.1956 7.3437C18.1497 7.23133 17.9847 6.88163 17.6492 6.71759C17.9458 6.71178 18.2805 6.82294 18.4323 6.97156C18.6148 6.68534 18.7328 6.49967 18.9162 6.18762C18.9599 6.11352 18.9831 5.97652 18.8996 5.89981C18.8996 5.89981 18.8992 5.89981 18.8988 5.89981C18.8988 5.89981 18.8988 5.8994 18.8988 5.899C18.8972 5.8974 18.8952 5.8962 18.8936 5.8946C18.892 5.893 18.891 5.89119 18.8892 5.88939C18.8892 5.88939 18.8888 5.88939 18.8884 5.88939C18.8884 5.88939 18.8884 5.88899 18.8884 5.88859C18.885 5.88518 18.8812 5.88258 18.8776 5.87938C18.8754 5.87717 18.8736 5.87457 18.8716 5.87217C18.8692 5.87016 18.8665 5.86836 18.8643 5.86616C18.8609 5.86275 18.8587 5.85855 18.8551 5.85534C18.8551 5.85534 18.8545 5.85514 18.8543 5.85534C18.8543 5.85534 18.8543 5.85494 18.8543 5.85454C18.8527 5.85294 18.8507 5.85174 18.8491 5.85013C18.8475 5.84853 18.8463 5.84653 18.8447 5.84493C18.8447 5.84493 18.8441 5.84473 18.8439 5.84493C18.8439 5.84493 18.8439 5.84453 18.8439 5.84413C18.7672 5.7606 18.6302 5.78384 18.5561 5.8275C18.1503 6.06625 17.7555 6.32322 17.3996 6.54855C17.3996 6.54855 16.9778 6.53973 16.4783 6.97116C16.3607 7.05989 16.2618 7.17125 16.1688 7.28902C16.167 7.29082 16.1654 7.29322 16.164 7.29503C16.1234 7.3465 16.0837 7.39898 16.0441 7.45145C15.7639 7.81939 15.5195 8.21556 15.2255 8.57128C14.9971 8.84768 13.2887 10.4348 12.8777 10.7264C12.7587 10.8109 12.6237 10.8474 12.4723 10.835C12.2379 10.8161 12.0298 10.6821 11.8965 10.4903L11.5704 9.93024C10.8527 8.78318 9.87332 7.82299 8.71264 7.12778C7.87262 6.62466 7.12514 5.98092 6.50264 5.22503L5.35778 3.83421C5.3013 3.76571 5.19314 3.77693 5.15268 3.85585C5.02249 4.10941 4.77393 4.64479 4.58346 5.36483C4.57885 5.38186 4.58286 5.39988 4.59407 5.4135C4.83082 5.69952 5.37901 6.32983 6.03196 6.863C6.07742 6.90005 6.04017 6.97336 5.98369 6.95774C5.42047 6.80432 4.87288 6.55796 4.46308 6.34805C4.42964 6.33103 4.38918 6.35226 4.38437 6.38951C4.32068 6.89985 4.30425 7.46027 4.37155 8.05112C4.37355 8.07035 4.38577 8.08697 4.4036 8.09479C4.87088 8.29808 5.61816 8.59311 6.40269 8.78078C6.45958 8.7944 6.45777 8.87632 6.40029 8.88733C5.78941 9.0023 5.14968 9.02794 4.62973 9.02113C4.59327 9.02073 4.56643 9.05518 4.57625 9.09023C4.6806 9.45896 4.822 9.8339 5.00847 10.2115C5.08559 10.3811 5.16951 10.5475 5.25944 10.7104C5.27486 10.7382 5.3047 10.7548 5.33655 10.7534C5.76577 10.7324 6.28452 10.6871 6.80608 10.595C6.89501 10.5794 6.94268 10.6964 6.86757 10.7466C6.51345 10.9834 6.13571 11.1873 5.7844 11.3551C5.73733 11.3777 5.72211 11.4378 5.75315 11.4797C5.96186 11.7625 6.19139 12.0301 6.44075 12.2794C6.44075 12.2794 7.66853 13.5441 7.70198 13.6432C8.41841 12.9096 9.59612 12.0964 10.8966 11.3864C9.15488 12.8036 8.18387 13.8499 7.69517 14.4444L7.35447 14.9225C7.17742 15.1708 7.02379 15.4346 6.89541 15.7112C6.46579 16.6356 5.75756 18.5051 5.75756 18.5051C5.70328 18.6515 5.74754 18.7959 5.84168 18.89C5.84388 18.8922 5.84609 18.8944 5.84849 18.8964C5.85069 18.8986 5.8527 18.901 5.8549 18.9032C5.94924 18.9976 6.09345 19.0416 6.23986 18.9874C6.23986 18.9874 8.10897 18.2791 9.03371 17.8495C9.31031 17.7211 9.57429 17.5673 9.82245 17.3905L10.349 17.0153C10.6278 16.8166 11.0096 16.8483 11.2517 17.0904L12.4655 18.3042C12.7148 18.5535 12.9824 18.7831 13.2652 18.9918C13.3073 19.0226 13.3672 19.0076 13.3898 18.9605C13.5579 18.6094 13.7618 18.2313 13.9983 17.8774C14.0486 17.8022 14.1657 17.8501 14.1499 17.9388C14.0576 18.4606 14.0127 18.9794 13.9915 19.4084C13.9899 19.44 14.0067 19.4701 14.0345 19.4855C14.1972 19.5756 14.3636 19.6595 14.5335 19.7364C14.911 19.9229 15.2862 20.0645 15.6547 20.1687C15.6897 20.1785 15.7242 20.1516 15.7238 20.1152C15.7168 19.595 15.7424 18.9553 15.8576 18.3446C15.8684 18.2869 15.9503 18.2851 15.9641 18.3422C16.1516 19.127 16.4466 19.8742 16.6501 20.3413C16.6579 20.3591 16.6744 20.3712 16.6938 20.3734C17.2847 20.4407 17.8451 20.4242 18.3554 20.3606C18.3929 20.3559 18.4141 20.3155 18.3969 20.2818C18.187 19.872 17.9406 19.3241 17.7872 18.7612C17.7718 18.7046 17.8449 18.6675 17.8819 18.713C18.4151 19.3659 19.0454 19.9141 19.3314 20.1508C19.345 20.1621 19.3633 20.1659 19.3801 20.1615C20.1003 19.9712 20.6357 19.7226 20.8891 19.5922C20.968 19.5518 20.9792 19.4436 20.9107 19.3871L20.9093 19.3861Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="#DFE1E5" d="M20.9093 19.3861L19.5185 18.2413C18.7624 17.619 18.1189 16.8713 17.6157 16.0313C16.9205 14.8706 15.9599 13.8912 14.8133 13.1735L14.2533 12.8475C14.0614 12.7141 13.9276 12.5062 13.9086 12.2716C13.8963 12.1204 13.9326 11.9852 14.0171 11.8662C14.3087 11.4553 15.896 9.74698 16.1722 9.51845C16.528 9.22442 16.9243 8.97987 17.2921 8.69986C17.3443 8.66 17.3968 8.62035 17.4485 8.57989C17.4503 8.57808 17.4529 8.57668 17.4545 8.57508C17.5725 8.48195 17.6838 8.383 17.7724 8.26563C18.2036 7.76631 18.195 7.3443 18.195 7.3443C18.195 7.3443 18.1954 7.3439 18.1956 7.3437C18.1497 7.23133 17.9847 6.88163 17.6492 6.71759C17.9458 6.71178 18.2805 6.82294 18.4323 6.97156C18.6148 6.68534 18.7328 6.49967 18.9162 6.18762C18.9599 6.11352 18.9831 5.97652 18.8996 5.89981C18.8996 5.89981 18.8992 5.89981 18.8988 5.89981C18.8988 5.89981 18.8988 5.8994 18.8988 5.899C18.8972 5.8974 18.8952 5.8962 18.8936 5.8946C18.892 5.893 18.891 5.89119 18.8892 5.88939C18.8892 5.88939 18.8888 5.88939 18.8884 5.88939C18.8884 5.88939 18.8884 5.88899 18.8884 5.88859C18.885 5.88518 18.8812 5.88258 18.8776 5.87938C18.8754 5.87717 18.8736 5.87457 18.8716 5.87217C18.8692 5.87016 18.8665 5.86836 18.8643 5.86616C18.8609 5.86275 18.8587 5.85855 18.8551 5.85534C18.8551 5.85534 18.8545 5.85514 18.8543 5.85534C18.8543 5.85534 18.8543 5.85494 18.8543 5.85454C18.8527 5.85294 18.8507 5.85174 18.8491 5.85013C18.8475 5.84853 18.8463 5.84653 18.8447 5.84493C18.8447 5.84493 18.8441 5.84473 18.8439 5.84493C18.8439 5.84493 18.8439 5.84453 18.8439 5.84413C18.7672 5.7606 18.6302 5.78384 18.5561 5.8275C18.1503 6.06625 17.7555 6.32322 17.3996 6.54855C17.3996 6.54855 16.9778 6.53973 16.4783 6.97116C16.3607 7.05989 16.2618 7.17125 16.1688 7.28902C16.167 7.29082 16.1654 7.29322 16.164 7.29503C16.1234 7.3465 16.0837 7.39898 16.0441 7.45145C15.7639 7.81939 15.5195 8.21556 15.2255 8.57128C14.9971 8.84768 13.2887 10.4348 12.8777 10.7264C12.7587 10.8109 12.6237 10.8474 12.4723 10.835C12.2379 10.8161 12.0298 10.6821 11.8965 10.4903L11.5704 9.93024C10.8527 8.78318 9.87332 7.82299 8.71264 7.12778C7.87262 6.62466 7.12514 5.98092 6.50264 5.22503L5.35778 3.83421C5.3013 3.76571 5.19314 3.77693 5.15268 3.85585C5.02249 4.10941 4.77393 4.64479 4.58346 5.36483C4.57885 5.38186 4.58286 5.39988 4.59407 5.4135C4.83082 5.69952 5.37901 6.32983 6.03196 6.863C6.07742 6.90005 6.04017 6.97336 5.98369 6.95774C5.42047 6.80432 4.87288 6.55796 4.46308 6.34805C4.42964 6.33103 4.38918 6.35226 4.38437 6.38951C4.32068 6.89985 4.30425 7.46027 4.37155 8.05112C4.37355 8.07035 4.38577 8.08697 4.4036 8.09479C4.87088 8.29808 5.61816 8.59311 6.40269 8.78078C6.45958 8.7944 6.45777 8.87632 6.40029 8.88733C5.78941 9.0023 5.14968 9.02794 4.62973 9.02113C4.59327 9.02073 4.56643 9.05518 4.57625 9.09023C4.6806 9.45896 4.822 9.8339 5.00847 10.2115C5.08559 10.3811 5.16951 10.5475 5.25944 10.7104C5.27486 10.7382 5.3047 10.7548 5.33655 10.7534C5.76577 10.7324 6.28452 10.6871 6.80608 10.595C6.89501 10.5794 6.94268 10.6964 6.86757 10.7466C6.51345 10.9834 6.13571 11.1873 5.7844 11.3551C5.73733 11.3777 5.72211 11.4378 5.75315 11.4797C5.96186 11.7625 6.19139 12.0301 6.44075 12.2794C6.44075 12.2794 7.66853 13.5441 7.70198 13.6432C8.41841 12.9096 9.59612 12.0964 10.8966 11.3864C9.15488 12.8036 8.18387 13.8499 7.69517 14.4444L7.35447 14.9225C7.17742 15.1708 7.02379 15.4346 6.89541 15.7112C6.46579 16.6356 5.75756 18.5051 5.75756 18.5051C5.70328 18.6515 5.74754 18.7959 5.84168 18.89C5.84388 18.8922 5.84609 18.8944 5.84849 18.8964C5.85069 18.8986 5.8527 18.901 5.8549 18.9032C5.94924 18.9976 6.09345 19.0416 6.23986 18.9874C6.23986 18.9874 8.10897 18.2791 9.03371 17.8495C9.31031 17.7211 9.57429 17.5673 9.82245 17.3905L10.349 17.0153C10.6278 16.8166 11.0096 16.8483 11.2517 17.0904L12.4655 18.3042C12.7148 18.5535 12.9824 18.7831 13.2652 18.9918C13.3073 19.0226 13.3672 19.0076 13.3898 18.9605C13.5579 18.6094 13.7618 18.2313 13.9983 17.8774C14.0486 17.8022 14.1657 17.8501 14.1499 17.9388C14.0576 18.4606 14.0127 18.9794 13.9915 19.4084C13.9899 19.44 14.0067 19.4701 14.0345 19.4855C14.1972 19.5756 14.3636 19.6595 14.5335 19.7364C14.911 19.9229 15.2862 20.0645 15.6547 20.1687C15.6897 20.1785 15.7242 20.1516 15.7238 20.1152C15.7168 19.595 15.7424 18.9553 15.8576 18.3446C15.8684 18.2869 15.9503 18.2851 15.9641 18.3422C16.1516 19.127 16.4466 19.8742 16.6501 20.3413C16.6579 20.3591 16.6744 20.3712 16.6938 20.3734C17.2847 20.4407 17.8451 20.4242 18.3554 20.3606C18.3929 20.3559 18.4141 20.3155 18.3969 20.2818C18.187 19.872 17.9406 19.3241 17.7872 18.7612C17.7718 18.7046 17.8449 18.6675 17.8819 18.713C18.4151 19.3659 19.0454 19.9141 19.3314 20.1508C19.345 20.1621 19.3633 20.1659 19.3801 20.1615C20.1003 19.9712 20.6357 19.7226 20.8891 19.5922C20.968 19.5518 20.9792 19.4436 20.9107 19.3871L20.9093 19.3861Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="#1F2023" d="M19.514 7.342C19.711 7.862 19.814 8.413 19.816 8.969C19.816 12.494 15.998 14.697 12.946 12.934C11.529 12.116 10.657 10.605 10.657 8.969C10.657 5.646 14.085 3.431 17.115 4.793C15.26 3.373 12.99 2.607 10.655 2.612C4.772 2.614 0.005 7.381 0 13.265C0.002 19.148 4.772 23.918 10.655 23.92C16.538 23.916 21.306 19.147 21.31 13.265C21.312 11.155 20.687 9.095 19.514 7.342ZM14.841 4.666C14.841 8.191 18.657 10.395 21.709 8.632C23.127 7.814 24 6.302 24 4.666C24 1.14 20.184-1.061 17.13 0.699C15.714 1.519 14.841 3.03 14.841 4.666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 641 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="#DFE1E5" d="M19.514 7.342C19.711 7.862 19.814 8.413 19.816 8.969C19.816 12.494 15.998 14.697 12.946 12.934C11.529 12.116 10.657 10.605 10.657 8.969C10.657 5.646 14.085 3.431 17.115 4.793C15.26 3.373 12.99 2.607 10.655 2.612C4.772 2.614 0.005 7.381 0 13.265C0.002 19.148 4.772 23.918 10.655 23.92C16.538 23.916 21.306 19.147 21.31 13.265C21.312 11.155 20.687 9.095 19.514 7.342ZM14.841 4.666C14.841 8.191 18.657 10.395 21.709 8.632C23.127 7.814 24 6.302 24 4.666C24 1.14 20.184-1.061 17.13 0.699C15.714 1.519 14.841 3.03 14.841 4.666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 641 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M18.8 0H5.2A5.2 5.2 0 000 5.2v13.6A5.2 5.2 0 005.2 24h13.6a5.2 5.2 0 005.2-5.2V5.2A5.2 5.2 0 0018.8 0z" fill="#9046FF" /> <path d="M7.97 16.376c-1.644 3.642 1.86 4.556 4.443 2.424.76 2.39 3.608.607 4.631-1.247 2.251-4.084 1.342-8.249 1.108-9.108-1.6-5.859-9.6-5.869-10.976.03-.323 1.033-.328 2.206-.507 3.423-.09.617-.16 1.009-.393 1.655-.139.373-.323.7-.62 1.257-.458.865-.264 2.53 2.101 1.665l.224-.1h-.01l-.001.001z" fill="#fff" /> <path d="M12.722 10.985c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.218-.97a.61.61 0 01.537-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005zm2.703 0c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.219-.97a.61.61 0 01.536-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005z" fill="#000" /></svg>
|
||||
|
After Width: | Height: | Size: 895 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 14H3V2H13V14ZM10.5 4.4H5.5V11.6H10.5V4.4Z" fill="#1F2023"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 216 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 14H3V2H13V14ZM10.5 4.4H5.5V11.6H10.5V4.4Z" fill="#DFE1E5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 216 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 141.38 140" width="16" height="16"><path fill="#615CED" d="m140.93 85-16.35-28.33-1.93-3.34 8.66-15a3.323 3.323 0 0 0 0-3.34l-9.62-16.67c-.3-.51-.72-.93-1.22-1.22s-1.07-.45-1.67-.45H82.23l-8.66-15a3.33 3.33 0 0 0-2.89-1.67H51.43c-.59 0-1.17.16-1.66.45-.5.29-.92.71-1.22 1.22L32.19 29.98l-1.92 3.33H12.96c-.59 0-1.17.16-1.66.45-.5.29-.93.71-1.22 1.22L.45 51.66a3.323 3.323 0 0 0 0 3.34l18.28 31.67-8.66 15a3.32 3.32 0 0 0 0 3.34l9.62 16.67c.3.51.72.93 1.22 1.22s1.07.45 1.67.45h36.56l8.66 15a3.35 3.35 0 0 0 2.89 1.67h19.25a3.34 3.34 0 0 0 2.89-1.67l18.28-31.67h17.32c.6 0 1.17-.16 1.67-.45s.92-.71 1.22-1.22l9.62-16.67a3.323 3.323 0 0 0 0-3.34ZM51.44 3.33 61.07 20l-9.63 16.66h76.98l-9.62 16.66H45.67l-11.54-20zM57.21 120H22.58l9.63-16.67h19.25l-38.5-66.67h19.25l9.62 16.67L68.78 100l-11.55 20Zm61.59-33.34-9.62-16.67-38.49 66.67-9.63-16.67 9.63-16.66 26.94-46.67h23.1l17.32 30z"/></svg>
|
||||
|
After Width: | Height: | Size: 941 B |
@@ -0,0 +1,12 @@
|
||||
<svg width="16" height="16" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.8147 5.35803H5.35791V8.46914H8.8147V5.35803Z" fill="#FFD800"/>
|
||||
<path d="M22.6419 5.35803H19.1851V8.46914H22.6419V5.35803Z" fill="#FFD800"/>
|
||||
<path d="M15.7283 15.7284H12.2715V18.8395H15.7283V15.7284Z" fill="#FA500F"/>
|
||||
<path d="M8.8147 15.7284H5.35791V18.8395H8.8147V15.7284Z" fill="#FA500F"/>
|
||||
<path d="M22.6419 15.7284H19.1851V18.8395H22.6419V15.7284Z" fill="#FA500F"/>
|
||||
<path d="M12.2715 8.81482H5.35791V11.9259H12.2715V8.81482Z" fill="#FFAF00"/>
|
||||
<path d="M12.2718 19.1852H1.90137V22.2963H12.2718V19.1852Z" fill="#E10500"/>
|
||||
<path d="M26.0989 19.1852H15.7285V22.2963H26.0989V19.1852Z" fill="#E10500"/>
|
||||
<path d="M22.6419 12.2716H5.35791V15.3827H22.6419V12.2716Z" fill="#FF8205"/>
|
||||
<path d="M22.6421 8.81482H15.7285V11.9259H22.6421V8.81482Z" fill="#FFAF00"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 869 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: Agents tool window (sparkle) -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.5 2L7.73 5.27L11 6.5L7.73 7.73L6.5 11L5.27 7.73L2 6.5L5.27 5.27L6.5 2Z" stroke="#6C707E" stroke-linejoin="round"/><path d="M11.5 9.5L12.2 11.3L14 12L12.2 12.7L11.5 14.5L10.8 12.7L9 12L10.8 11.3L11.5 9.5Z" stroke="#6C707E" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 411 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: Agents tool window (sparkle), stripe size -->
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 2.5L9.55 6.45L13.5 8L9.55 9.55L8 13.5L6.45 9.55L2.5 8L6.45 6.45L8 2.5Z" stroke="#6C707E" stroke-width="1.2" stroke-linejoin="round"/><path d="M14.5 12L15.4 14.1L17.5 15L15.4 15.9L14.5 18L13.6 15.9L11.5 15L13.6 14.1L14.5 12Z" stroke="#6C707E" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 463 B |
@@ -103,6 +103,11 @@ export interface EditorKindContribution {
|
||||
component: ComponentType<{ input: EditorInput }>;
|
||||
title: (input: EditorInput) => string;
|
||||
icon?: (input: EditorInput) => string;
|
||||
/**
|
||||
* Its tabs are saved with the editor session and reopened with the project (their
|
||||
* `data` must be plain JSON). The editor closes tabs whose target is gone itself.
|
||||
*/
|
||||
restorable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -371,6 +376,14 @@ export interface OpenOptions {
|
||||
asText?: boolean;
|
||||
}
|
||||
|
||||
export interface OpenEditorTab {
|
||||
input: EditorInput;
|
||||
title: string;
|
||||
icon: string;
|
||||
/** Activation order this session (higher is more recent); 0 if never active. */
|
||||
lastActive: number;
|
||||
}
|
||||
|
||||
export interface EditorService {
|
||||
openFile(path: string, options?: OpenOptions): Promise<void>;
|
||||
openInput(input: EditorInput, options?: OpenOptions): Promise<void>;
|
||||
@@ -384,6 +397,8 @@ export interface EditorService {
|
||||
focus?(): void;
|
||||
/** Paths of the documents open in editor tabs (all panes, tab order). */
|
||||
openPaths?(): string[];
|
||||
/** Every open tab, files or not, most recently active first (Switcher). */
|
||||
openTabs?(): OpenEditorTab[];
|
||||
/** Closes every tab showing `path`. */
|
||||
closePath?(path: string): void;
|
||||
/** Closes the tab of the input with this `key` (e.g. a merge editor once applied). */
|
||||
|
||||