дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(agents): Dispatch and supervise coding agents over ACP (#72)
## What An Agents feature: hand a task to a coding agent and follow it from the IDE. Agents speak the [Agent Client Protocol](https://agentclientprotocol.com). Built in: Claude Code, Codex, Gemini CLI, GitHub Copilot, Cursor, OpenCode, Qwen Code, Kimi CLI, Goose, Auggie, Kiro, Mistral Vibe. You can add your own in `<config>/agents/<id>.toml` (`name`, `command`, `args`, `[env]`). An agent whose command isn't on `PATH` is listed under "Not installed". - **New Task page.** An editor page with a prompt box. You pick the agent, model, mode and thinking effort, and whether it runs in a new worktree (from a chosen branch) or in the checkout. - **Agents tool window.** Tasks are grouped by what they need: Needs Input, Failed, Running, Ready to Review, Archived. It also shows a stripe badge, a status bar item and notifications. - **Session tab.** - The conversation: Markdown, thinking, tool calls with diff stats, the plan. - Permission requests appear as an editor banner. - The changed files are compared against the task's base commit and open in the diff viewer. - A context-usage meter. - **Composer.** - `@` references files from the project index. A referenced file's text is sent when the agent accepts embedded context, otherwise a `resource_link`. - `/` lists the agent's commands; ↑/↓ recall earlier messages. - Images can be pasted, dropped or picked; Shift+Tab cycles the mode. - While the agent works, Enter queues the message and ⌘↵ interrupts the turn and sends it now. Queued messages can be sent now, edited or removed. - **Send to Agent.** In the editor and Project view context menus. It adds `@file:10-20` references to the message you typed last. - Landing the work is left to the existing Git features: the worktree is a normal branch. ## How - **`crates/ide-agent`**: one ACP agent process per task, running in the task's directory. - It folds `session/update` into a timeline and answers `session/request_permission`. - It tracks the session's settings: `configOptions`, falling back to the older `modes` / `models` fields. The user's choices are applied again on every new or reloaded session. - Tasks are saved as JSON per task. Sessions resume with `session/load` without duplicating history, and old task files are upgraded when read. - **`ide-jsonrpc`**: adds newline-delimited framing (ACP) next to Content-Length (LSP). - **`ide-vcs`**: adds `numstat_with_worktree` for the changes list. - **Editor**: - The Switcher (⌃⇥) lists every open editor, as IDEA does, not just files. - `EditorKindContribution.restorable` lets a kind's tabs be saved with the editor session, so agent tabs survive reloads. - **UI**: `Select` popups now size to fit short option details (they used to cut them off). ## Testing - `cargo test -p ide-agent` (14 tests). They use an in-memory ACP agent and cover permissions, restart with `session/load`, settings, the queue with send-now and stop, and prompt blocks. - There is also an ignored end-to-end test against the real Claude Code adapter: `OPENIDEA_AGENT_E2E=1 cargo test -p ide-agent real_claude -- --ignored`. - `pnpm vitest run`: 2052 tests. - `cargo clippy -p ide-agent -p openidea --all-targets -- -D warnings` and typecheck are clean. - Driven in the running app with Claude Code: - A task in a worktree, with permission answers, changes listed and the diff opened. - A follow-up after restarting the IDE. - `@` reference with embedded content, `/` commands, Shift+Tab mode changes. - A queued message and send-now, an image prompt, Send to Agent from a selection. - The Switcher and restored tabs after a reload. Not covered yet: only Claude Code was driven end to end. The other agents go through the same ACP path but weren't run here. Merged on the maintainer's go-ahead. Merge with main: kept both sides in Cargo.toml (ide-agent + ide-clipboard), the feature registry (agents + vim) and zh.json (no duplicate keys). After the merge: typecheck clean, full vitest 268 files / 2247 tests passed, ide-agent/ide-jsonrpc/ide-vcs 144 passed, bindings unchanged after regeneration.
Цей коміт міститься в:
1 джерело
b4742b730b
коміт
2eaa6cb0c7
75 files changed
+6377
-63
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Заблокувати користувача