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

+99
Переглянути файл
@@ -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;
}
+29
Переглянути файл
@@ -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>
);
}
+128
Переглянути файл
@@ -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);
}
+145
Переглянути файл
@@ -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);
}
+366
Переглянути файл
@@ -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>
);
}
+57
Переглянути файл
@@ -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
}
/>
);
})}
</>
);
}
+53
Переглянути файл
@@ -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);
}
+137
Переглянути файл
@@ -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>
);
}
+29
Переглянути файл
@@ -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 }}
/>
);
}
+406
Переглянути файл
@@ -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);
}
+451
Переглянути файл
@@ -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 },
);
}
+167
Переглянути файл
@@ -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");
});
});
+133
Переглянути файл
@@ -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, "\\$&");
}
+113
Переглянути файл
@@ -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();
};
}
+90
Переглянути файл
@@ -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;
}
+36
Переглянути файл
@@ -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;
}
+310
Переглянути файл
@@ -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));
+2
Переглянути файл
@@ -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],
];
+23 -1
Переглянути файл
@@ -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 });
+30 -12
Переглянути файл
@@ -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}
+51 -1
Переглянути файл
@@ -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.