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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 14:44:28 +08:00
1 джерело b4742b730b
коміт 2eaa6cb0c7
75 files changed
+6377 -63

No files matched your search

+58 -2
Переглянути файл
@@ -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>();
+1
Переглянути файл
@@ -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) => {
+25
Переглянути файл
@@ -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();
+45 -17
Переглянути файл
@@ -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 };
+1 -24
Переглянути файл
@@ -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]);