дзеркало
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>();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in new issue
Заблокувати користувача