дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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.
Цей коміт міститься в:
1 джерело
b4742b730b
коміт
2eaa6cb0c7
75 files changed
+6377
-63
No files matched your search
@@ -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>();
|
||||
|
||||
Reference in new issue
Заблокувати користувача