дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
## 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.
172 рядки
5.3 KiB
TypeScript
172 рядки
5.3 KiB
TypeScript
import { useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
|
import clsx from "clsx";
|
|
import { Icon, type IconName } from "./icons";
|
|
import { Popup } from "./Popup";
|
|
import { List, listNavigate } from "./List";
|
|
import { useTx } from "@/platform/i18n";
|
|
import styles from "./Select.module.css";
|
|
|
|
export interface SelectOption<V extends string = string> {
|
|
value: V;
|
|
label: string;
|
|
icon?: IconName | (string & {});
|
|
detail?: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export interface SelectProps<V extends string = string> {
|
|
value: V | null;
|
|
options: readonly SelectOption<V>[];
|
|
onChange: (value: V) => void;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
/** `ghost` renders as plain text + chevron (title bar widgets). */
|
|
variant?: "framed" | "ghost";
|
|
className?: string;
|
|
"aria-label"?: string;
|
|
/** Custom trigger content. */
|
|
renderValue?: (option: SelectOption<V> | undefined) => ReactNode;
|
|
}
|
|
|
|
/** Dropdown select with a listbox popup (↑↓ Enter Esc, type-ahead). */
|
|
export function Select<V extends string = string>({
|
|
value,
|
|
options,
|
|
onChange,
|
|
placeholder = "Select…",
|
|
disabled,
|
|
variant = "framed",
|
|
className,
|
|
renderValue,
|
|
...aria
|
|
}: SelectProps<V>) {
|
|
const tx = useTx();
|
|
const [open, setOpen] = useState(false);
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
const current = options.find((o) => o.value === value);
|
|
const [active, setActive] = useState(0);
|
|
const isSection = (o: SelectOption<V>) => !!o.disabled;
|
|
|
|
const openPopup = () => {
|
|
setActive(
|
|
Math.max(
|
|
0,
|
|
options.findIndex((o) => o.value === value),
|
|
),
|
|
);
|
|
setOpen(true);
|
|
};
|
|
|
|
const choose = (o: SelectOption<V> | undefined) => {
|
|
if (!o || o.disabled) return;
|
|
onChange(o.value);
|
|
setOpen(false);
|
|
};
|
|
|
|
const onPopupKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
choose(options[active]);
|
|
return;
|
|
}
|
|
const next = listNavigate(e.key, { items: options, active, isSection, wrap: true });
|
|
if (next !== null) {
|
|
e.preventDefault();
|
|
setActive(next);
|
|
return;
|
|
}
|
|
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) {
|
|
const ch = e.key.toLowerCase();
|
|
const n = options.length;
|
|
for (let s = 1; s <= n; s++) {
|
|
const i = (active + s) % n;
|
|
if (!options[i]!.disabled && tx(options[i]!.label).toLowerCase().startsWith(ch)) {
|
|
setActive(i);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
aria-label={tx(aria["aria-label"])}
|
|
disabled={disabled}
|
|
className={clsx(styles.trigger, styles[variant], className)}
|
|
onClick={() => (open ? setOpen(false) : openPopup())}
|
|
onKeyDown={(e) => {
|
|
if (!open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
|
e.preventDefault();
|
|
openPopup();
|
|
}
|
|
}}
|
|
>
|
|
{renderValue ? (
|
|
<span className={styles.value}>{renderValue(current)}</span>
|
|
) : (
|
|
<>
|
|
{current?.icon && <Icon name={current.icon} size={14} />}
|
|
<span className={clsx(styles.value, !current && styles.placeholder)}>
|
|
{tx(current?.label ?? placeholder)}
|
|
</span>
|
|
</>
|
|
)}
|
|
<Icon name="chevron-down" size={14} className={styles.chevron} />
|
|
</button>
|
|
<Popup
|
|
open={open}
|
|
onClose={() => setOpen(false)}
|
|
anchor={triggerRef}
|
|
matchWidth
|
|
role="presentation"
|
|
className={styles.popup}
|
|
>
|
|
<div tabIndex={-1} data-autofocus onKeyDown={onPopupKey} className={styles.popupInner}>
|
|
{/* Virtualised rows are absolutely positioned; this sizes the popup to the widest label. */}
|
|
<div aria-hidden className={styles.sizer}>
|
|
{options.map((o) => (
|
|
<div key={o.value} className={styles.sizerRow}>
|
|
<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>
|
|
<List
|
|
items={options}
|
|
getKey={(o) => o.value}
|
|
isSection={isSection}
|
|
renderSection={(o) => <span className={styles.disabled}>{tx(o.label)}</span>}
|
|
activeIndex={active}
|
|
onActiveChange={setActive}
|
|
onActivate={(o) => choose(o)}
|
|
focusable={false}
|
|
hoverSelects
|
|
rowHeight={24}
|
|
aria-label={tx(aria["aria-label"])}
|
|
className={styles.list}
|
|
renderItem={(o) => (
|
|
<>
|
|
<span className={styles.check}>{o.value === value && <Icon name="check" size={12} />}</span>
|
|
{o.icon && <Icon name={o.icon} size={14} />}
|
|
<span className={clsx(styles.optionLabel, o.detail && styles.withDetail)}>{tx(o.label)}</span>
|
|
{o.detail && (
|
|
<span className={styles.detail} title={o.detail}>
|
|
{tx(o.detail)}
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
/>
|
|
</div>
|
|
</Popup>
|
|
</>
|
|
);
|
|
}
|