Compare commits

..
1 Commits
Author SHA1 Message Date
l0ng-ai 56727e3c4b test(i18n): allow CLI to stay untranslated
The New CLI tab added "CLI" to the catalogs; like Shell and other
abbreviations it reads the same in every locale.
2026-11-13 22:26:58 +08:00
35 changed files with 747 additions and 3409 deletions

No files matched your search

+14 -43
View File
@@ -71,14 +71,7 @@ async function openReply(page: Page, text: string, imageData?: string, theme?: "
[
task.id,
[
{
type: "user",
text: "Show Markdown",
attachments: [],
sentMs: 0,
doneMs: 1,
steered: false,
},
{ type: "user", text: "Show Markdown", attachments: [], sentMs: 0, doneMs: 1, steered: false },
{ type: "agent", text },
],
],
@@ -118,11 +111,7 @@ test("scrolls a wide table inside the reply", async ({ page }) => {
const reply = await openReply(page, REPLY);
const sizes = await reply.evaluate((el) => {
const table = el.querySelector("table")!;
return {
reply: el.clientWidth,
table: table.clientWidth,
content: table.scrollWidth,
};
return { reply: el.clientWidth, table: table.clientWidth, content: table.scrollWidth };
});
expect(sizes.table).toBeLessThanOrEqual(sizes.reply);
expect(sizes.content).toBeGreaterThan(sizes.table);
@@ -159,10 +148,7 @@ test("loads local reply images, and web ones when asked", async ({ page }) => {
let fetched = 0;
await page.route("https://images.example.com/code.gif", (route) => {
fetched++;
return route.fulfill({
contentType: "image/gif",
body: Buffer.from(PIXEL.split(",")[1]!, "base64"),
});
return route.fulfill({ contentType: "image/gif", body: Buffer.from(PIXEL.split(",")[1]!, "base64") });
});
const reply = await openReply(
page,
@@ -172,9 +158,7 @@ test("loads local reply images, and web ones when asked", async ({ page }) => {
const width = (name: string) =>
reply.getByRole("img", { name, includeHidden: true }).evaluate((img: HTMLImageElement) => img.naturalWidth);
await expect.poll(() => width("Local QR")).toBe(1);
const load = reply.getByRole("button", {
name: "Load image from images.example.com",
});
const load = reply.getByRole("button", { name: "Load image from images.example.com" });
await expect(load).toBeVisible();
expect(fetched).toBe(0);
await load.click();
@@ -219,10 +203,12 @@ test("keeps latest reply actions visible and quotes into the draft without shift
await page.getByRole("button", { name: "Quote Reply", exact: true }).click();
const draft = page.getByRole("textbox", { name: "Message", exact: true });
await expect(draft).toBeFocused();
// Quoted as `> ` lines, drawn as a block without their markers, an empty line to type in after it.
const quoted = REPLY.trimEnd().split("\n");
await expect(draft.locator(".cm-line")).toHaveText([...quoted, "", ""]);
await expect(draft.locator(".cm-line[class*='_quoteLine_']")).toHaveCount(quoted.length);
await expect(draft).toHaveValue(
REPLY.trimEnd()
.split("\n")
.map((line) => (line ? `> ${line}` : ">"))
.join("\n") + "\n\n",
);
});
test("copies a mouse selection with Ctrl+C and pops up no Quote button", async ({ page, context }) => {
@@ -245,14 +231,7 @@ test("keeps chat history readable and selections free of timestamps and status l
await page.evaluate(async (task) => {
const path = "/src/features/agents/store.ts";
const store = await import(path);
const user = (text: string) => ({
type: "user",
text,
attachments: [],
sentMs: 1,
doneMs: 5001,
steered: false,
});
const user = (text: string) => ({ type: "user", text, attachments: [], sentMs: 1, doneMs: 5001, steered: false });
store.useAgents.setState({
entries: new Map([
[
@@ -347,9 +326,7 @@ test("keeps hover actions reachable when the pointer leaves the message body", a
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
await reply.hover();
const box = await copy.boundingBox();
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, {
steps: 20,
});
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2, { steps: 20 });
await expect(copy.locator("..")).toHaveCSS("opacity", "1");
await copy.click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("A short answer.");
@@ -491,10 +468,7 @@ for (const theme of ["dark", "light"] as const) {
const bar = document.getElementById(el.id.replace(/-text$/, ""))!;
const fg = luminance(getComputedStyle(el).fill);
const bg = luminance(getComputedStyle(bar).fill);
return {
text: el.textContent,
ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05),
};
return { text: el.textContent, ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05) };
});
});
expect(contrasts).toHaveLength(4);
@@ -506,10 +480,7 @@ test("draws a diagram without reaching the hosts its configuration names", async
const asked: string[] = [];
await page.route(/^https:\/\/([a-z\d-]+\.)*example\.com\//, (route) => {
asked.push(new URL(route.request().url()).pathname);
return route.fulfill({
contentType: "image/gif",
body: Buffer.from(PIXEL.split(",")[1]!, "base64"),
});
return route.fulfill({ contentType: "image/gif", body: Buffer.from(PIXEL.split(",")[1]!, "base64") });
});
const at = (name: string) => `https://images.example.com/${name}`;
const fence = (...lines: string[]) => ["```mermaid", ...lines, "```"].join("\n");
+63 -128
View File
@@ -22,62 +22,58 @@
inset 0 0 0 1px var(--focus-ring),
0 0 0 1px var(--focus-ring);
}
/* The CodeMirror text field: the UI font, wrapping, growing with the text up to a limit. */
.field {
position: relative;
min-width: 0;
}
.field :global(.cm-editor) {
background: transparent;
color: var(--fg-strong);
font-size: var(--fs-ui);
}
.field :global(.cm-editor.cm-focused) {
outline: none;
}
.field :global(.cm-scroller) {
min-height: 44px;
max-height: 280px;
overflow-y: auto;
font-family: inherit;
line-height: 1.5;
}
.field :global(.cm-content) {
/* Tags under the transparent textarea: same font, metrics and padding, so the caret
and selection of the real textarea line up with the painted text. */
.mirror {
position: absolute;
inset: 0;
overflow: hidden;
padding: 10px 12px 2px;
caret-color: var(--fg-strong);
font: inherit;
font-size: var(--fs-ui);
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.field :global(.cm-line) {
padding: 0;
}
/* A wide card (a ``` block) takes the text's width, its code scrolling inside, rather than
widening the text past the box. */
.field :global(.cm-content) {
min-width: 0;
}
.field :global(.cm-placeholder) {
color: var(--fg-faint);
}
.field :global(.cm-cursor) {
border-left-color: var(--fg-strong);
}
.field ::selection {
background: var(--bg-editor-selection);
}
/* A dragged file over the box: what dropping does. */
.dropOverlay {
position: absolute;
z-index: 2;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: inherit;
background: color-mix(in srgb, var(--bg-popup) 82%, transparent);
color: var(--fg-strong);
pointer-events: none;
-webkit-user-select: none;
user-select: none;
}
.textarea {
position: relative;
z-index: 1;
display: block;
width: 100%;
min-height: 44px;
resize: none;
border: 0;
outline: none;
background: transparent;
color: transparent;
caret-color: var(--fg-strong);
font: inherit;
font-size: var(--fs-ui);
line-height: 1.5;
padding: 10px 12px 2px;
overflow-wrap: anywhere;
-webkit-user-select: text;
user-select: text;
}
.textarea:focus-visible {
outline: none;
}
.textarea::placeholder {
color: var(--fg-faint);
}
/* The textarea's own glyphs are transparent so the mirror above shows the tags; the
selection would then paint an empty highlight. Paint the selected glyphs here instead —
the opaque selection background covers the mirror underneath. */
.textarea::selection {
background: var(--bg-editor-selection);
color: var(--fg-strong);
}
.bar {
display: flex;
@@ -351,19 +347,17 @@
color: var(--fg-subtle);
}
/* Above the text: the current file to reference, attached files and images. */
.context {
.images {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 8px 10px 0;
cursor: default;
}
.image {
position: relative;
width: 40px;
height: 40px;
width: 44px;
height: 44px;
border-radius: var(--radius-small);
box-shadow: inset 0 0 0 1px var(--border);
overflow: hidden;
@@ -399,63 +393,21 @@
opacity: 0;
}
.image:hover .removeImage,
.attachment:hover .removeImage,
.removeImage:focus-visible {
opacity: 1;
}
/* Attached files and the current-file suggestion: one height, like the chips in the text. */
.attachment,
.suggestion {
.attachment {
position: relative;
display: inline-flex;
align-items: center;
gap: 5px;
gap: 6px;
max-width: 240px;
height: 24px;
padding: 0 4px 0 7px;
border-radius: var(--radius-control);
box-shadow: inset 0 0 0 1px var(--border);
font-size: var(--fs-small);
color: var(--fg-strong);
}
.suggestion {
padding-right: 8px;
border: 0;
background: none;
box-shadow: none;
outline: 1px dashed var(--border-strong);
outline-offset: -1px;
color: var(--fg-muted);
cursor: pointer;
transition:
color var(--dur-fast) var(--ease),
background var(--dur-fast) var(--ease);
}
.suggestion:hover {
background: var(--bg-hover);
color: var(--fg-strong);
}
.suggestion:focus-visible {
outline: 2px solid var(--focus-ring);
}
.suggestionHint {
color: var(--fg-faint);
}
.removeChip {
display: grid;
place-items: center;
width: 16px;
height: 16px;
padding: 0;
border: 0;
height: 44px;
padding: 0 10px;
border-radius: var(--radius-small);
background: none;
color: var(--fg-muted);
cursor: pointer;
}
.removeChip:hover {
background: var(--bg-hover);
color: var(--fg-strong);
box-shadow: inset 0 0 0 1px var(--border);
}
.attachmentName {
overflow: hidden;
@@ -464,6 +416,16 @@
}
/* `@` / `/` completion; Popup keeps the list within the viewport. */
.caretMirror {
position: fixed;
top: 0;
left: 0;
box-sizing: border-box;
visibility: hidden;
pointer-events: none;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.popup {
display: flex;
flex-direction: column;
@@ -491,30 +453,3 @@
text-overflow: ellipsis;
color: var(--fg-subtle);
}
.match {
background: none;
color: var(--fg-completion-match, var(--accent));
font-weight: 600;
}
.popupFooter {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
padding: 5px 12px 6px;
border-top: 1px solid var(--border);
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.popupFooter kbd {
display: inline-grid;
place-items: center;
min-width: 16px;
height: 16px;
margin-right: 2px;
padding: 0 3px;
border-radius: var(--radius-small);
background: var(--bg-hover);
color: var(--fg-muted);
font-family: var(--font-keys, inherit);
font-size: var(--fs-tiny, 11px);
}
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -31,8 +31,7 @@
display: flex;
flex-wrap: wrap;
align-items: center;
/* The chips' own padding spaces them. */
gap: 2px;
gap: 6px;
padding: 0 2px;
font-size: var(--fs-small);
}
@@ -68,6 +67,7 @@
.docked .where {
flex-wrap: nowrap;
min-width: 0;
gap: 6px;
}
.docked .projectPicker,
.docked .projectChip {
+15 -1
View File
@@ -11,6 +11,7 @@ import { Marked } from "marked";
import DOMPurify from "dompurify";
import { highlightInto } from "@/editor/lsp/markdown";
import { iconNode } from "@/editor/lsp/iconMarkup";
import { call, commands } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { openExternalUrl } from "@/platform/openUrl";
import { copyText } from "@/ui";
@@ -19,7 +20,6 @@ import { hasMermaid, renderMermaidIn } from "@/features/viewers/markdown/mermaid
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
import { fileHref, pathLink, type PathLink } from "./conversation";
import { loadImages, type MessageImages } from "./markdownImages";
import { fileExists } from "./ReferenceChip";
import styles from "./Session.module.css";
const escapeHtml = (s: string) =>
@@ -167,6 +167,20 @@ function wrapLooseText(li: Element) {
flush();
}
/** Whether a path names an existing file, per path (agents name the same files again and again). */
const existing = new Map<string, Promise<boolean>>();
function fileExists(path: string): Promise<boolean> {
let known = existing.get(path);
if (!known) {
known = call(commands.fsProbe(path)).then(
() => true,
() => false,
);
existing.set(path, known);
}
return known;
}
export function openPathLink(link: PathLink) {
const selection = link.line === undefined ? undefined : { line: link.line, col: link.col ?? 0 };
void editorService().openFile(link.path, { selection, focus: true });
@@ -1,262 +0,0 @@
/* An `@file` / `/skill` reference: icon, name and line range, in the composer and in sent messages. */
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
height: 20px;
padding: 0 6px 0 4px;
margin: 0 1px;
vertical-align: -4px;
border-radius: var(--radius-selection);
background: color-mix(in srgb, var(--fg) 9%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 14%, transparent);
color: var(--fg-strong);
font-size: var(--fs-small);
line-height: 20px;
white-space: nowrap;
cursor: pointer;
user-select: none;
transition:
background var(--dur-fast) var(--ease),
box-shadow var(--dur-fast) var(--ease);
}
.chip:hover {
background: color-mix(in srgb, var(--accent) 16%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent);
}
.chip:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--focus-ring);
}
.chip > svg,
.chip > img {
flex: none;
}
.name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.lines {
flex: none;
color: var(--fg-muted);
font-variant-numeric: tabular-nums;
}
/* The file is gone (renamed or deleted since it was referenced). */
.missing {
color: var(--fg-muted);
background: transparent;
box-shadow: none;
outline: 1px dashed var(--border-strong);
outline-offset: -1px;
}
.missing .name {
text-decoration: line-through;
}
/* Selected together with the text around it in the composer. */
.selected {
background: var(--bg-editor-selection);
}
/* A `/command` or skill. */
.command {
display: inline;
padding: 1px 3px;
margin: 0 -3px;
border-radius: var(--radius-selection);
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 16%, transparent);
}
/* `@something` that names no referenced file: as typed, just marked. */
.pending {
color: var(--fg-strong);
text-decoration: underline dotted color-mix(in srgb, var(--fg) 40%, transparent);
text-underline-offset: 3px;
}
/* A quoted reply or selection in a sent message: as a Markdown quote in a reply. */
.quote {
margin: 2px 0 6px;
padding: 0 10px;
border-left: 3px solid var(--border-strong);
color: var(--fg-secondary);
}
/* Hover card: the full path, and the referenced lines (or the file's first lines). */
.card {
position: fixed;
z-index: 1000;
top: 0;
left: 0;
width: max-content;
max-width: min(520px, calc(100vw - 24px));
border-radius: var(--radius-popup);
background: var(--bg-popup);
box-shadow:
0 0 0 1px var(--border-popup),
var(--shadow-popup);
color: var(--fg);
font-size: var(--fs-small);
pointer-events: none;
overflow: hidden;
}
.cardHead {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
}
.cardPath {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg-strong);
}
.cardMeta {
flex: none;
margin-left: auto;
padding-left: 12px;
color: var(--fg-subtle);
}
.cardCode {
display: flex;
margin: 0;
padding: 6px 10px 8px;
border-top: 1px solid var(--border);
background: var(--bg-editor-inline, var(--bg-elev));
color: var(--fg-editor);
font-family: var(--font-mono);
font-size: var(--fs-small);
line-height: 1.5;
white-space: pre;
overflow: hidden;
tab-size: 4;
}
.cardLine {
flex: none;
min-width: 3ch;
margin-right: 10px;
color: var(--fg-line-number);
text-align: right;
user-select: none;
}
/* A quote in the composer: its lines as one block, the `>` hidden. */
.quoteLine {
margin-left: 0;
padding-left: 10px !important;
border-left: 3px solid var(--border-strong);
color: var(--fg-secondary);
}
.quoteFirst {
margin-top: 2px;
}
.quoteLast {
margin-bottom: 4px;
}
/* Sent message text: lays out as the text it wraps. */
.text {
display: contents;
}
/* A ``` block (pasted code, terminal output): a card with a title, the line count and the code. */
.codeCard {
display: block;
box-sizing: border-box;
max-width: 100%;
min-width: 0;
margin: 4px 0 6px;
border-radius: var(--radius-control);
background: var(--bg-editor-inline, var(--bg-elev));
box-shadow: inset 0 0 0 1px var(--border);
white-space: normal;
overflow: hidden;
cursor: default;
}
.codeHead {
display: flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 4px 0 8px;
border-bottom: 1px solid var(--border);
color: var(--fg-strong);
font-size: var(--fs-small);
}
.codeTitle {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.codeMeta {
flex: none;
margin-left: auto;
color: var(--fg-subtle);
}
.codeRemove,
.codeCopy {
display: grid;
flex: none;
place-items: center;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: var(--radius-small);
background: none;
color: var(--fg-muted);
cursor: pointer;
}
.codeRemove:hover,
.codeCopy:hover {
background: var(--bg-hover);
color: var(--fg-strong);
}
.codeBody {
margin: 0;
padding: 6px 10px;
color: var(--fg-editor);
font-family: var(--font-mono);
font-size: var(--fs-small);
line-height: 1.5;
white-space: pre;
overflow-x: auto;
tab-size: 4;
}
/* More lines than shown: the last ones fade out. */
.codeFaded {
-webkit-mask-image: linear-gradient(to bottom, var(--fg) 55%, transparent);
mask-image: linear-gradient(to bottom, var(--fg) 55%, transparent);
}
.codeMore {
display: block;
width: 100%;
height: 24px;
border: 0;
border-top: 1px solid var(--border);
background: none;
color: var(--link);
font-size: var(--fs-small);
cursor: pointer;
}
.codeMore:hover {
background: var(--bg-hover);
}
/* In the composer: the card opens as text on a click. */
.codeCard[title] {
cursor: pointer;
}
.codeCard[title]:hover {
box-shadow: inset 0 0 0 1px var(--border-strong);
}
/* A ``` block being edited as text in the composer. */
.codeLine {
font-family: var(--font-mono);
font-size: var(--fs-small);
background: var(--bg-editor-inline, var(--bg-elev));
}
-401
View File
@@ -1,401 +0,0 @@
/**
* `@file` / `@folder/` / `@file:10-20` references drawn as chips (icon, name, lines), in the
* composer and in sent messages: a click opens the file at its lines (a folder is selected in
* the Project view), hovering shows the full path and the lines.
*/
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import { computePosition, flip, offset, shift } from "@floating-ui/react-dom";
import { highlightInto, languageForPath } from "@/editor/lsp/markdown";
import { iconNode } from "@/editor/lsp/iconMarkup";
import { executeAction } from "@/platform/actions";
import { tx, useTx } from "@/platform/i18n";
import { call, commands } from "@/platform/ipc";
import { basename } from "@/platform/project";
import { copyText, fileIconName, Icon } from "@/ui";
import { editorService } from "@/workbench/contributions";
import {
fenceLabel,
fencedBlocks,
isQuoteLine,
linesLabel,
messageTokens,
parseMention,
resolveTyped,
type Mention,
} from "./composerModel";
import styles from "./ReferenceChip.module.css";
export { styles as chipStyles };
/** Whether a path names an existing file or folder, per path (the same files come up again and again). */
const existing = new Map<string, Promise<boolean>>();
export function fileExists(path: string): Promise<boolean> {
let known = existing.get(path);
if (!known) {
known = call(commands.fsProbe(path)).then(
() => true,
() => false,
);
existing.set(path, known);
}
return known;
}
/** Opens a referenced file at its first line, or selects a folder in the Project view. */
export function openReference(path: string, mention: Mention) {
if (mention.dir) {
void executeAction("navigate.selectInProjectView", path.replace(/\/$/, ""));
return;
}
const selection = mention.lines ? { line: mention.lines.from - 1, col: 0 } : undefined;
void editorService().openFile(path, { selection, focus: true });
}
const nameOf = (m: Mention) => basename(m.rel.replace(/\/$/, "")) || m.rel;
const iconOf = (m: Mention) => (m.dir ? "folder" : fileIconName(nameOf(m), m.rel));
/** The chip as DOM, for the composer's editor. */
export function chipElement(path: string, mention: Mention): HTMLElement {
const chip = document.createElement("span");
chip.className = styles.chip!;
chip.dataset.refPath = path;
chip.dataset.refRel = mention.rel;
if (mention.lines) chip.dataset.refLines = linesLabel(mention.lines);
chip.setAttribute("aria-label", ariaLabel(mention));
chip.append(iconNode(iconOf(mention), 14));
const name = document.createElement("span");
name.className = styles.name!;
name.textContent = nameOf(mention);
chip.append(name);
if (mention.lines) {
const lines = document.createElement("span");
lines.className = styles.lines!;
lines.textContent = linesLabel(mention.lines);
chip.append(lines);
}
if (!mention.dir)
void fileExists(path).then((exists) => {
if (!exists) chip.classList.add(styles.missing!);
});
return chip;
}
function ariaLabel(m: Mention): string {
const name = nameOf(m);
if (m.dir) return tx`Folder reference ${name}`;
if (!m.lines) return tx`File reference ${name}`;
const lines = linesLabel(m.lines);
return tx`File reference ${name}, lines ${lines}`;
}
/** A reference in a sent message. */
export function ReferenceChip({ path, mention }: { path: string; mention: Mention }) {
const [missing, setMissing] = useState(false);
useEffect(() => {
let alive = true;
if (!mention.dir) void fileExists(path).then((exists) => alive && setMissing(!exists));
return () => {
alive = false;
};
}, [path, mention.dir]);
return (
<button
type="button"
className={clsx(styles.chip, missing && styles.missing)}
data-ref-path={path}
data-ref-rel={mention.rel}
data-ref-lines={mention.lines ? linesLabel(mention.lines) : undefined}
aria-label={ariaLabel(mention)}
onClick={() => openReference(path, mention)}
>
<Icon name={iconOf(mention)} size={14} />
<span className={styles.name}>{nameOf(mention)}</span>
{mention.lines && <span className={styles.lines}>{linesLabel(mention.lines)}</span>}
</button>
);
}
/**
* Message text with its references as chips, `/commands` tagged, `>` quotes and ``` blocks as cards.
* `cwd` is the directory references resolve in.
*/
export function ReferencedText({ text, cwd }: { text: string; cwd: string }) {
const [root, setRoot] = useState<HTMLSpanElement | null>(null);
useReferenceHover(root);
const all = text.split("\n");
const blocks: ({ kind: "quote" | "text"; text: string } | { kind: "code"; info: string; text: string })[] = [];
const push = (kind: "quote" | "text", body: string) => {
const last = blocks.at(-1);
if (last && last.kind === kind) last.text += `\n${body}`;
else blocks.push({ kind, text: body });
};
let i = 0;
for (const fence of [...fencedBlocks(all), null]) {
const end = fence ? fence.open : all.length;
for (; i < end; i++) {
const line = all[i]!;
const quote = isQuoteLine(line);
push(quote ? "quote" : "text", quote ? line.replace(/^> ?/, "") : line);
}
if (!fence) break;
blocks.push({ kind: "code", info: fence.info, text: all.slice(fence.open + 1, fence.close).join("\n") });
i = fence.close + 1;
}
return (
<span ref={setRoot} className={styles.text}>
{blocks.map((block, n) => {
if (block.kind === "code") return <CodeCard key={n} info={block.info} body={block.text} />;
if (block.kind === "quote")
return (
<div key={n} className={styles.quote}>
{block.text}
</div>
);
// A block ends its own line: the paragraph after it starts without an empty one.
const body = n > 0 ? block.text.replace(/^\n/, "") : block.text;
return <Fragment key={n}>{inline(body, cwd)}</Fragment>;
})}
</span>
);
}
function inline(text: string, cwd: string): ReactNode[] {
return messageTokens(text).map((token, i) => {
if (token.kind === "text") return <Fragment key={i}>{token.value}</Fragment>;
if (token.kind === "command")
return (
<span key={i} className={styles.command}>
{token.value}
</span>
);
const mention = parseMention(token.value);
if (!mention) return <Fragment key={i}>{token.value}</Fragment>;
return <ReferenceChip key={i} path={resolveTyped(cwd, mention.rel)} mention={mention} />;
});
}
const HOVER_DELAY = 400;
const PREVIEW_LINES = 12;
const MAX_PREVIEW_BYTES = 2 * 1024 * 1024;
/**
* Hover cards for the chips below `root`: the full path, and the referenced lines (a file's
* first lines without a range). Returns a disposer.
*/
export function referenceHover(root: HTMLElement): () => void {
let timer: ReturnType<typeof setTimeout> | undefined;
let card: HTMLElement | null = null;
let current: HTMLElement | null = null;
const hide = () => {
clearTimeout(timer);
card?.remove();
card = null;
current = null;
};
const place = (anchor: HTMLElement, el: HTMLElement) =>
void computePosition(anchor, el, {
strategy: "fixed",
placement: "top-start",
middleware: [offset(6), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
el.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
});
const show = (chip: HTMLElement) => {
const path = chip.dataset.refPath!;
const rel = chip.dataset.refRel!;
const mention = parseMention(
`@${rel}${chip.dataset.refLines ? `:${chip.dataset.refLines.replace("–", "-")}` : ""}`,
);
if (!mention) return;
const el = document.createElement("div");
el.className = styles.card!;
el.setAttribute("role", "tooltip");
const head = document.createElement("div");
head.className = styles.cardHead!;
head.append(iconNode(iconOf(mention), 14));
const name = document.createElement("span");
name.className = styles.cardPath!;
name.textContent = rel.replace(/\/$/, "");
head.append(name);
const meta = document.createElement("span");
meta.className = styles.cardMeta!;
const lines = mention.lines && linesLabel(mention.lines);
meta.textContent = chip.classList.contains(styles.missing!)
? tx("File not found")
: lines
? tx`Lines ${lines}`
: mention.dir
? tx("Folder")
: "";
head.append(meta);
el.append(head);
document.body.append(el);
card = el;
place(chip, el);
if (mention.dir || chip.classList.contains(styles.missing!)) return;
void preview(path, mention).then((code) => {
if (card !== el || !code) return;
el.append(code);
place(chip, el);
});
};
const over = (e: PointerEvent) => {
const chip = (e.target as Element | null)?.closest?.<HTMLElement>("[data-ref-path]") ?? null;
if (chip === current) return;
hide();
if (!chip || !root.contains(chip)) return;
current = chip;
timer = setTimeout(() => show(chip), HOVER_DELAY);
};
root.addEventListener("pointerover", over);
root.addEventListener("pointerleave", hide);
root.addEventListener("pointerdown", hide, true);
root.addEventListener("keydown", hide, true);
root.addEventListener("scroll", hide, true);
return () => {
hide();
root.removeEventListener("pointerover", over);
root.removeEventListener("pointerleave", hide);
root.removeEventListener("pointerdown", hide, true);
root.removeEventListener("keydown", hide, true);
root.removeEventListener("scroll", hide, true);
};
}
async function preview(path: string, mention: Mention): Promise<HTMLElement | null> {
try {
const probe = await call(commands.fsProbe(path));
if (probe.binary || probe.size > MAX_PREVIEW_BYTES) return null;
const all = (await call(commands.fsReadText(path))).split("\n");
const first = mention.lines ? Math.min(mention.lines.from, all.length) : 1;
const last = Math.min(mention.lines ? mention.lines.to : PREVIEW_LINES, first + PREVIEW_LINES - 1, all.length);
const lines = all.slice(first - 1, last).map((l) => l.replace(/\r$/, "").replace(/\t/g, " "));
while (lines.length && !lines.at(-1)!.trim()) lines.pop();
if (!lines.length) return null;
const indent = Math.min(...lines.filter((l) => l.trim()).map((l) => /^ */.exec(l)![0].length));
const text = lines.map((l) => l.slice(indent)).join("\n");
const code = document.createElement("pre");
code.className = styles.cardCode!;
const gutter = document.createElement("span");
gutter.className = styles.cardLine!;
gutter.textContent = lines.map((_, i) => first + i).join("\n");
const body = document.createElement("code");
body.textContent = text;
code.append(gutter, body);
await highlightInto(body, text, languageForPath(path));
return code;
} catch {
return null;
}
}
function useReferenceHover(root: HTMLElement | null) {
const t = useTx();
useEffect(() => (root ? referenceHover(root) : undefined), [root, t]);
}
const KIND_TITLES = { terminal: "Terminal", problems: "Problems", trace: "Stack Trace", text: "Pasted Text" } as const;
const KIND_ICONS = { terminal: "terminal", problems: "problems", trace: "exception", text: "text" } as const;
/** A ``` block's header: icon and title ("Terminal · zsh", "TypeScript"). */
function cardTitle(info: string): { icon: string; title: string } {
const { kind, label, language } = fenceLabel(info);
if (kind !== "code") {
const title = tx(KIND_TITLES[kind]);
return { icon: KIND_ICONS[kind], title: label ? `${title} · ${label}` : title };
}
const name = language && label === language ? languageForPath(`x.${language}`) : null;
return { icon: language ? fileIconName(`x.${language}`) : "file", title: name ?? (label || tx("Code")) };
}
const lineCount = (count: number, t: typeof tx) => (count === 1 ? t("1 line") : t`${count} lines`);
const COMPOSER_PREVIEW_LINES = 5;
/**
* A ``` block in the composer, folded to a card: its title, line count and first lines.
* `onOpen` edits it as text; `onRemove` deletes it.
*/
export function codeCardElement(info: string, body: string, onOpen: () => void, onRemove: () => void): HTMLElement {
const { icon, title } = cardTitle(info);
const lines = body.split("\n");
const card = document.createElement("div");
card.className = styles.codeCard!;
card.setAttribute("role", "group");
card.setAttribute("aria-label", title);
const head = document.createElement("div");
head.className = styles.codeHead!;
head.append(iconNode(icon, 14));
const name = document.createElement("span");
name.className = styles.codeTitle!;
name.textContent = title;
const meta = document.createElement("span");
meta.className = styles.codeMeta!;
meta.textContent = lineCount(lines.length, tx);
const remove = document.createElement("button");
remove.type = "button";
remove.className = styles.codeRemove!;
remove.setAttribute("aria-label", tx("Remove"));
remove.append(iconNode("close", 10));
remove.addEventListener("mousedown", (e) => e.preventDefault());
remove.addEventListener("click", (e) => {
e.stopPropagation();
onRemove();
});
head.append(name, meta, remove);
const pre = document.createElement("pre");
pre.className = clsx(styles.codeBody, lines.length > COMPOSER_PREVIEW_LINES && styles.codeFaded);
const shown = lines.slice(0, COMPOSER_PREVIEW_LINES).join("\n");
pre.textContent = shown;
const { language, kind } = fenceLabel(info);
if (kind === "code") void highlightInto(pre, shown, language);
card.append(head, pre);
card.title = tx("Click to edit");
card.addEventListener("mousedown", (e) => e.preventDefault());
card.addEventListener("click", onOpen);
return card;
}
const MESSAGE_PREVIEW_LINES = 12;
/** A ``` block in a sent message: a card with its title, the code highlighted, long ones folded. */
function CodeCard({ info, body }: { info: string; body: string }) {
const t = useTx();
const { icon, title } = cardTitle(info);
const lines = body.split("\n");
const [open, setOpen] = useState(lines.length <= MESSAGE_PREVIEW_LINES);
const code = useRef<HTMLPreElement>(null);
const shown = open ? body : lines.slice(0, MESSAGE_PREVIEW_LINES).join("\n");
useEffect(() => {
const el = code.current;
if (!el) return;
el.textContent = shown;
const { language, kind } = fenceLabel(info);
if (kind === "code") void highlightInto(el, shown, language);
}, [shown, info]);
const more = lines.length - MESSAGE_PREVIEW_LINES;
return (
<div className={styles.codeCard} role="group" aria-label={title}>
<div className={styles.codeHead}>
<Icon name={icon} size={14} />
<span className={styles.codeTitle}>{title}</span>
<span className={styles.codeMeta}>{lineCount(lines.length, t)}</span>
<button type="button" className={styles.codeCopy} aria-label={t("Copy")} onClick={() => void copyText(body)}>
<Icon name="copy" size={12} />
</button>
</div>
<pre ref={code} className={clsx(styles.codeBody, !open && styles.codeFaded)} />
{more > 0 && (
<button type="button" className={styles.codeMore} onClick={() => setOpen(!open)}>
{open ? t("Show less") : t`Show ${more} more lines`}
</button>
)}
</div>
);
}
+3 -18
View File
@@ -50,13 +50,11 @@
color: var(--fg-subtle);
font-size: var(--fs-small);
}
/* The facts on the bar's line are as tall as its chips, so their text shares one centre line. */
.agent {
display: inline-flex;
flex: 0 1 auto;
align-items: center;
gap: 4px;
height: 22px;
min-width: 0;
color: var(--fg-secondary);
}
@@ -71,8 +69,7 @@
display: inline-flex;
flex: 0 1 auto;
align-items: center;
/* The chips' own padding spaces them. */
gap: 2px;
gap: 6px;
min-width: 0;
overflow: hidden;
}
@@ -81,10 +78,7 @@
color: var(--fg-subtle);
}
.state {
display: inline-flex;
flex: none;
align-items: center;
height: 22px;
white-space: nowrap;
}
/* The changes sit above the meter, time and terminal; those keep their one row at the right. */
@@ -102,7 +96,6 @@
display: inline-flex;
flex: 0 1 auto;
align-items: center;
height: 22px;
gap: 8px;
max-width: 100%;
min-width: 0;
@@ -141,8 +134,8 @@
}
.statusChip:hover,
.statusChip[aria-expanded="true"] {
background: var(--bg-toolbar-hover);
color: var(--fg-strong);
background: var(--bg-hover);
color: var(--fg);
}
/* In a narrow docked session the chip is narrower than its text: the label ends in an ellipsis. */
.statusChipLabel {
@@ -317,14 +310,6 @@
--session-card-width: 96%;
}
}
/* A narrow docked session: rather than cut every fact to a letter or two, the bar keeps the
agent's icon and its state; where it works is in the task's tooltip and Changes. */
@container (max-width: 420px) {
.where,
.agentName {
display: none;
}
}
.messageActions {
display: inline-flex;
gap: 4px;
+30 -47
View File
@@ -43,6 +43,7 @@ import {
Icon,
IconButton,
Input,
InlineTag,
Radio,
SearchField,
Spinner,
@@ -63,7 +64,6 @@ import { commitFileDiffData, openDiff, withChain, type DiffData } from "@/featur
import { repositoryPath } from "@/features/vcs/repositoryPaths";
import { setChangesOptions, useChangesOptions } from "./changesOptions";
import { Composer } from "./Composer";
import { ReferencedText } from "./ReferenceChip";
import { selectionIn } from "./selection";
import { SubmitButton } from "./SubmitButton";
import {
@@ -72,7 +72,6 @@ import {
emptyDraft,
limitLabel,
messageTokens,
parseMention,
retryParts,
usageLabel,
workTime,
@@ -352,7 +351,7 @@ function SessionStatus({
showContextMenu(e, taskMenu([task]));
}}
>
<span className={styles.agent} title={`${providerName(task.provider)} · ${task.cwd}`}>
<span className={styles.agent} title={task.cwd}>
<Icon name={providerIcon(task.provider)} size={13} className={styles.providerIcon} />
<span className={styles.agentName}>
{providerName(task.provider)}
@@ -921,10 +920,7 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
const laidOut =
el.scrollHeight !== seenHeight.current.content || el.clientHeight !== seenHeight.current.view;
seenHeight.current = {
content: el.scrollHeight,
view: el.clientHeight,
};
seenHeight.current = { content: el.scrollHeight, view: el.clientHeight };
const byUser = dragging.current || performance.now() - userScrolled.current < 500 || !laidOut;
const movedDown = el.scrollTop > position.current.top;
const stayedAtEnd = position.current.stick && el.scrollTop === position.current.top;
@@ -1013,6 +1009,23 @@ function Stream({ loadError, task, entries, subagent, onOpenSubagent, finding, f
type UserEntry = Extract<AgentEntry, { type: "user" }>;
/** Message text with file references and skills as tags, distinct from prose. */
function MessageText({ text }: { text: string }) {
return (
<>
{messageTokens(text).map((token, i) =>
token.kind === "text" ? (
<Fragment key={i}>{token.value}</Fragment>
) : (
<InlineTag key={i} accent={token.kind === "command"}>
{token.value}
</InlineTag>
),
)}
</>
);
}
/** When a message was sent, or when the turn behind a reply finished, and how long it took. */
function MessageTime({
sentMs,
@@ -1417,7 +1430,7 @@ const Entry = memo(function Entry({
<T>Task from the parent agent</T>
</div>
<div className={styles.user}>
<ReferencedText text={entry.text} cwd={task.cwd} />
<MessageText text={entry.text} />
</div>
</div>
) : (
@@ -1481,14 +1494,7 @@ function UserMessage({
}
const images = entry.images ?? [];
// Images are listed after the files in `attachments` and shown as thumbnails instead.
// Files mentioned in the text are chips there already.
const mentioned = new Set(
messageTokens(entry.text).flatMap((t) => {
const m = t.kind === "file" ? parseMention(t.value) : null;
return m ? [basename(m.rel.replace(/\/$/, ""))] : [];
}),
);
const files = entry.attachments.slice(0, entry.attachments.length - images.length).filter((a) => !mentioned.has(a));
const files = entry.attachments.slice(0, entry.attachments.length - images.length);
return (
<div className={clsx(styles.message, styles.userMessage)}>
{/* Actions and the time sit beside the author on hover: the reply under it has the turn's duration. */}
@@ -1535,7 +1541,7 @@ function UserMessage({
</span>
</div>
<div className={styles.user}>
<ReferencedText text={entry.text} cwd={task.cwd} />
<MessageText text={entry.text} />
<ImageStrip images={images} />
{files.length > 0 && (
<div className={styles.attachments}>
@@ -2667,7 +2673,7 @@ function PendingPermission({ task, entry }: { task: AgentTask; entry: Permission
if (!el?.contains(document.activeElement) && document.activeElement !== document.body) return;
requestAnimationFrame(() => {
if (document.activeElement === document.body || !document.activeElement?.isConnected)
area?.querySelector<HTMLElement>(".cm-content")?.focus({ preventScroll: true });
area?.querySelector<HTMLElement>("textarea")?.focus({ preventScroll: true });
});
};
}, []);
@@ -2946,12 +2952,7 @@ function changeRows(
commits: readonly AgentCommit[],
grouped: boolean,
): ChangeRow[] {
if (!grouped)
return uncommitted.map((change) => ({
key: change.path,
type: "file" as const,
change,
}));
if (!grouped) return uncommitted.map((change) => ({ key: change.path, type: "file" as const, change }));
const roots: ChangeRow[] = [];
if (uncommitted.length) {
roots.push({
@@ -2960,11 +2961,7 @@ function changeRows(
label: "Uncommitted",
count: uncommitted.length,
...statsOf(uncommitted),
children: uncommitted.map((change) => ({
key: `uncommitted:${change.path}`,
type: "file",
change,
})),
children: uncommitted.map((change) => ({ key: `uncommitted:${change.path}`, type: "file", change })),
});
}
if (commits.length) {
@@ -3009,10 +3006,7 @@ function ChangesPane({
onHide: () => void;
}) {
// The two views key their rows differently: a selection doesn't carry over.
const [selection, setSelection] = useState<{
grouped: boolean;
keys: ReadonlySet<string>;
}>(() => ({
const [selection, setSelection] = useState<{ grouped: boolean; keys: ReadonlySet<string> }>(() => ({
grouped,
keys: NO_SELECTION,
}));
@@ -3116,10 +3110,7 @@ function ChangesPane({
body: tx`You changed them yourself, accept them by hand: ${skippedNames}.`,
});
} else {
notifications().notify({
level: "info",
title: tx("Changes accepted"),
});
notifications().notify({ level: "info", title: tx("Changes accepted") });
}
} catch (e) {
notifications().notify({
@@ -3316,10 +3307,7 @@ function uncommittedDiffData(task: AgentTask, change: AgentChange): DiffData {
const right: DiffData["right"] =
change.kind === "deleted"
? { type: "text", text: "", label: "(deleted)", absent: true }
: {
type: "worktree",
label: task.isolation === "worktree" ? branchLabel(task.branch) : "Working tree",
};
: { type: "worktree", label: task.isolation === "worktree" ? branchLabel(task.branch) : "Working tree" };
return { path: change.path, left, right };
}
@@ -3348,12 +3336,7 @@ async function openUncommittedDiff(task: AgentTask, path: string) {
async function openCommitDiff(task: AgentTask, commit: AgentCommit, path: string) {
const diffs = commit.files.map((file) =>
commitFileDiffData(
{
id: commit.id,
shortId: commit.shortId,
parents: commit.parent ? [commit.parent] : [],
repo: task.cwd,
},
{ id: commit.id, shortId: commit.shortId, parents: commit.parent ? [commit.parent] : [], repo: task.cwd },
{ path: file.relPath, kind: file.kind, oldPath: file.oldPath },
),
);
+8 -97
View File
@@ -19,13 +19,6 @@ import {
recall,
toPrompt,
mergeMatches,
messageTokens,
fenced,
fencedBlocks,
fenceLabel,
pastedPaths,
looksLikeTrace,
placesInText,
retryParts,
usageLabel,
limitLabel,
@@ -79,53 +72,6 @@ const option = (category: string, values: string[], current: string | null): Age
via: "option",
});
describe("fenced blocks", () => {
it("finds closed blocks, a longer fence holding backticks", () => {
const text = fenced("a ``` b\n```", "terminal zsh");
expect(text).toBe("````terminal zsh\na ``` b\n```\n````");
expect(fencedBlocks(["x", ...text.split("\n"), "```ts", "open"])).toEqual([
{ open: 1, close: 4, info: "terminal zsh" },
]);
expect(fenceLabel("terminal zsh")).toEqual({ kind: "terminal", label: "zsh", language: null });
expect(fenceLabel("trace")).toEqual({ kind: "trace", label: "", language: null });
expect(fenceLabel("ts")).toEqual({ kind: "code", label: "ts", language: "ts" });
});
});
describe("pasted traces", () => {
const node =
"TypeError: x is undefined\n at fetchUser (/p/src/api/client.ts:12:5)\n at node_modules/lib/x.js:3:1";
const python =
'Traceback (most recent call last):\n File "/p/app/main.py", line 7, in <module>\n run()\nValueError: bad';
const rust = "thread 'main' panicked at src/main.rs:4:5:\nboom";
it("recognises stack traces, not prose", () => {
expect([node, python, rust].map(looksLikeTrace)).toEqual([true, true, true]);
expect(looksLikeTrace("see src/a.ts:3\nand more")).toBe(false);
expect(looksLikeTrace("Error: one line")).toBe(false);
});
it("finds the places a trace names, leaving dependencies out", () => {
expect(placesInText(node)).toEqual([{ path: "/p/src/api/client.ts", line: 12 }]);
expect(placesInText(python)).toEqual([{ path: "/p/app/main.py", line: 7 }]);
expect(placesInText(rust)).toEqual([{ path: "src/main.rs", line: 4 }]);
expect(placesInText("a.ts:1 a.ts:1 b.ts:2", 1)).toEqual([{ path: "a.ts", line: 1 }]);
});
});
describe("pastedPaths", () => {
it("reads absolute paths with lines, quotes and file URLs, one per line", () => {
expect(pastedPaths("/a/b.ts:10-20\n '/a/c d.ts' \nfile:///a/%E4%B8%AD.ts\n/a/dir/\n/a/e.ts:3:7")).toEqual([
{ path: "/a/b.ts", lines: { from: 10, to: 20 } },
{ path: "/a/c d.ts" },
{ path: "/a/中.ts" },
{ path: "/a/dir" },
{ path: "/a/e.ts", lines: { from: 3, to: 3 } },
]);
expect(pastedPaths("C:\\work\\a.ts:4")).toEqual([{ path: "C:\\work\\a.ts", lines: { from: 4, to: 4 } }]);
expect(pastedPaths("/a/b.ts\nnot a path")).toBeNull();
expect(pastedPaths("see /a/b.ts")).toBeNull();
});
});
describe("mergeMatches", () => {
it("ranks folders among files by score, files first on a tie", () => {
const merged = mergeMatches(
@@ -140,38 +86,14 @@ describe("mergeMatches", () => {
3,
);
expect(merged).toEqual([
{ path: "/p/docs", rel: "docs", dir: true, score: 70, indices: [] },
{
path: "/p/src/agents.ts",
rel: "src/agents.ts",
dir: false,
score: 50,
indices: [],
},
{
path: "/p/src/agents",
rel: "src/agents",
dir: true,
score: 50,
indices: [],
},
{ path: "/p/docs", rel: "docs", dir: true, score: 70 },
{ path: "/p/src/agents.ts", rel: "src/agents.ts", dir: false, score: 50 },
{ path: "/p/src/agents", rel: "src/agents", dir: true, score: 50 },
]);
});
});
describe("agent composer", () => {
it("leaves punctuation after a reference or command to the text", () => {
expect(messageTokens("in @src/a.ts:3-8, keep @src/retry/。then /review.")).toEqual([
{ kind: "text", value: "in " },
{ kind: "file", value: "@src/a.ts:3-8" },
{ kind: "text", value: ", keep " },
{ kind: "file", value: "@src/retry/" },
{ kind: "text", value: "。then " },
{ kind: "command", value: "/review" },
{ kind: "text", value: "." },
]);
});
it("completes @files and /commands anywhere at a word boundary", () => {
expect(completionAt("fix @src/le", 11)).toEqual({
kind: "file",
@@ -264,11 +186,7 @@ describe("agent composer", () => {
expect(toPrompt({ text: "explain @src/", files: [folder], images: [] }).files).toEqual(["/w/src"]);
expect(toPrompt({ text: "explain @src/a.rs", files: [folder], images: [] }).files).toEqual([]);
expect(canSend({ text: " ", files: [], images: [] })).toBe(false);
const video = {
path: "/home/me/clip.mov",
rel: "clip.mov",
attached: true,
};
const video = { path: "/home/me/clip.mov", rel: "clip.mov", attached: true };
expect(toPrompt({ text: "what happens here?", files: [video], images: [] }).files).toEqual(["/home/me/clip.mov"]);
expect(canSend({ text: " ", files: [video], images: [] })).toBe(true);
});
@@ -569,23 +487,16 @@ describe("new task defaults", () => {
description: null,
category: id,
current,
values: values.map((value) => ({
value,
name: value,
description: null,
})),
values: values.map((value) => ({ value, name: value, description: null })),
via: "option",
}) as AgentConfigOption;
const provider = {
id: "claude",
config: [option("model", "sonnet", ["sonnet", "opus"]), option("mode", "default", ["default", "plan"])],
} as unknown as Parameters<typeof draftConfig>[0];
const draft = {
provider: "claude",
config: {},
isolation: "local",
base: null,
} as unknown as Parameters<typeof draftConfig>[1];
const draft = { provider: "claude", config: {}, isolation: "local", base: null } as unknown as Parameters<
typeof draftConfig
>[1];
const current = (d: typeof draft, defaults: Record<string, string>) =>
draftConfig(provider, d, defaults).map((o) => o.current);
expect(current(draft, { model: "opus", mode: "nope" })).toEqual(["opus", "default"]);
+72 -96
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sendKeyAction } from "./Composer";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -25,13 +25,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
},
};
});
type DragDrop = {
payload: {
type: string;
paths?: string[];
position?: { x: number; y: number };
};
};
type DragDrop = { payload: { type: string; paths?: string[]; position?: { x: number; y: number } } };
const dragDrop = new Set<(event: DragDrop) => void>();
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({
@@ -49,11 +43,12 @@ vi.mock("@/platform/tauriCore", async (importOriginal) => ({
import { isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { usePlatform } from "@/platform/platformInfo";
import type { ProjectInfo } from "@/bindings";
import { defaultTaskProject } from "./project";
import { HomeEditor } from "./HomeEditor";
import chipStyles from "./ReferenceChip.module.css";
import { textOf, typeIn } from "./composerTesting";
import composerStyles from "./Composer.module.css";
import tagStyles from "@/ui/InlineTag.module.css";
import { composerDraft, HOME, updateComposer, useAgents } from "./store";
import { emptyDraft } from "./composerModel";
@@ -87,21 +82,14 @@ beforeEach(() => {
vi.mocked(isTauri).mockReturnValue(true);
useProject.setState({ project: null });
vcsBranches.mockReset().mockResolvedValue({ status: "ok", data: [] });
vcsBranchInfo.mockReset().mockImplementation((root: string | null) =>
Promise.resolve({
status: "ok",
data: { head: root === "/project-b" ? "b-main" : "a-main" },
}),
);
vcsBranchInfo
.mockReset()
.mockImplementation((root: string | null) =>
Promise.resolve({ status: "ok", data: { head: root === "/project-b" ? "b-main" : "a-main" } }),
);
agentCommands.mockReset();
clipboardImage.mockReset().mockResolvedValue({
status: "ok",
data: "data:image/png;base64,iVBORw0KGgo=",
});
agentCreate.mockReset().mockResolvedValue({
status: "error",
error: { kind: "invalid", message: "stop here" },
});
clipboardImage.mockReset().mockResolvedValue({ status: "ok", data: "data:image/png;base64,iVBORw0KGgo=" });
agentCreate.mockReset().mockResolvedValue({ status: "error", error: { kind: "invalid", message: "stop here" } });
useAgents.setState({
providers: [claude],
draft: { provider: "claude", config: {}, isolation: "local", base: null },
@@ -118,11 +106,11 @@ describe("New Task composer commands", () => {
render(<HomeEditor />);
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
const box = screen.getByRole("textbox");
typeIn(box, "/");
fireEvent.change(box, { target: { value: "/" } });
await screen.findByRole("listbox", { name: "Commands" });
for (let i = 0; i < 29; i++) fireEvent.keyDown(box, { key: "ArrowDown" });
fireEvent.keyDown(box, { key: "Enter" });
expect(textOf(box)).toBe("/command-29 ");
expect(box).toHaveValue("/command-29 ");
});
it("groups commands before skills, skips headers, wraps and hides empty groups", async () => {
@@ -133,7 +121,7 @@ describe("New Task composer commands", () => {
render(<HomeEditor />);
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
const box = screen.getByRole("textbox");
typeIn(box, "/");
fireEvent.change(box, { target: { value: "/" } });
const list = await screen.findByRole("listbox", { name: "Commands" });
expect(
within(list)
@@ -149,11 +137,11 @@ describe("New Task composer commands", () => {
expect(within(list).getByRole("option", { selected: true })).toHaveTextContent("/review");
fireEvent.keyDown(box, { key: "ArrowDown" });
expect(within(list).getByRole("option", { selected: true })).toHaveTextContent("/compact");
typeIn(box, "/tri");
fireEvent.change(box, { target: { value: "/tri" } });
expect(list.textContent).toBe("Skills/triage");
fireEvent.keyDown(box, { key: "Tab" });
expect(textOf(box)).toBe("/triage ");
typeIn(box, "/com");
expect(box).toHaveValue("/triage ");
fireEvent.change(box, { target: { value: "/com" } });
expect((await screen.findByRole("listbox", { name: "Commands" })).textContent).toBe("Commands/compact");
});
@@ -166,7 +154,7 @@ describe("New Task composer commands", () => {
expect(start.textContent).toBe("");
expect(start).toHaveAttribute("data-tooltip", "Start");
expect(start).toBeDisabled();
typeIn(screen.getByRole("textbox"), "Do the task");
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Do the task" } });
expect(start).toBeEnabled();
fireEvent.click(start);
const starting = screen.getByRole("button", { name: "Starting…" });
@@ -175,12 +163,7 @@ describe("New Task composer commands", () => {
expect(starting).toHaveAttribute("data-tooltip", "Starting…");
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
expect(agentCreate).toHaveBeenCalledTimes(1);
await act(async () =>
finish({
status: "error",
error: { kind: "invalid", message: "stop here" },
}),
);
await act(async () => finish({ status: "error", error: { kind: "invalid", message: "stop here" } }));
expect(screen.getByRole("button", { name: "Start" })).toBeEnabled();
});
@@ -196,7 +179,7 @@ describe("New Task composer commands", () => {
render(<HomeEditor />);
await waitFor(() => expect(agentCommands).toHaveBeenCalledWith("claude", null));
const box = screen.getByRole("textbox");
typeIn(box, "/trig");
fireEvent.change(box, { target: { value: "/trig" } });
const list = await screen.findByRole("listbox", { name: "Commands" });
const options = within(list).getAllByRole("option");
expect(options.map((o) => o.textContent)).toEqual(["/triageOne pass over open issues and PRs"]);
@@ -205,83 +188,95 @@ describe("New Task composer commands", () => {
expect(useAgents.getState().composers.get(HOME)?.text).toBe("/triage ");
// Sent as typed, so the agent runs the skill.
typeIn(box, "/triage dry-run");
fireEvent.change(box, { target: { value: "/triage dry-run" } });
fireEvent.keyDown(box, { key: "Enter" });
await waitFor(() => expect(agentCreate).toHaveBeenCalled());
expect(agentCreate.mock.calls[0]![0]).toMatchObject({
prompt: { text: "/triage dry-run" },
provider: "claude",
});
expect(agentCreate.mock.calls[0]![0]).toMatchObject({ prompt: { text: "/triage dry-run" }, provider: "claude" });
});
it("tags file references and skills where they stand", async () => {
agentCommands.mockResolvedValue({
status: "ok",
data: [command("triage", "One pass over issues")],
});
agentCommands.mockResolvedValue({ status: "ok", data: [command("triage", "One pass over issues")] });
render(<HomeEditor />);
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
const box = screen.getByRole("textbox");
typeIn(box, "12 @Cargo.toml and /triage");
expect(within(box).getByText("@Cargo.toml")).toHaveClass(chipStyles.pending!);
expect(within(box).getByText("/triage")).toHaveClass(chipStyles.command!);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "12 @Cargo.toml and /triage" } });
const mirror = document.querySelector(`.${composerStyles.mirror}`)!;
expect(within(mirror as HTMLElement).getByText("@Cargo.toml")).toHaveClass(tagStyles.tag!);
expect(within(mirror as HTMLElement).getByText("/triage")).toHaveClass(tagStyles.accent!);
});
it("offers nothing the project does not have", async () => {
agentCommands.mockResolvedValue({
status: "ok",
data: [command("compact", "Free up context")],
});
agentCommands.mockResolvedValue({ status: "ok", data: [command("compact", "Free up context")] });
render(<HomeEditor />);
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
const box = screen.getByRole("textbox");
typeIn(box, "/trig");
fireEvent.change(box, { target: { value: "/trig" } });
expect(screen.queryByRole("listbox", { name: "Commands" })).toBeNull();
typeIn(box, "/com");
fireEvent.change(box, { target: { value: "/com" } });
expect(await screen.findByRole("option")).toHaveTextContent("/compact");
});
it("completes at the caret without replacing the rest of the message", async () => {
const user = userEvent.setup();
agentCommands.mockResolvedValue({
status: "ok",
data: [command("compact", "Free up context")],
});
agentCommands.mockResolvedValue({ status: "ok", data: [command("compact", "Free up context")] });
render(<HomeEditor />);
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
const box = screen.getByRole("textbox");
box.focus();
typeIn(box, "/com keep this context", 4);
fireEvent.change(box, { target: { value: "/com keep this context", selectionStart: 4, selectionEnd: 4 } });
expect(await screen.findByRole("option")).toHaveTextContent("/compact");
expect(box).toHaveFocus();
await user.keyboard("{Tab}");
expect(textOf(box)).toBe("/compact keep this context");
expect(box).toHaveValue("/compact keep this context");
await waitFor(() => expect(screen.queryByRole("listbox", { name: "Commands" })).toBeNull());
expect(agentCreate).not.toHaveBeenCalled();
});
it("completes a command typed after other text in place", async () => {
agentCommands.mockResolvedValue({
status: "ok",
data: [command("triage", "One pass over open issues and PRs")],
});
agentCommands.mockResolvedValue({ status: "ok", data: [command("triage", "One pass over open issues and PRs")] });
render(<HomeEditor />);
await waitFor(() => expect(agentCommands).toHaveBeenCalled());
const box = screen.getByRole("textbox");
typeIn(box, "fix the failing build /tri");
fireEvent.change(box, { target: { value: "fix the failing build /tri" } });
await screen.findByRole("listbox", { name: "Commands" });
fireEvent.keyDown(box, { key: "Enter" });
expect(useAgents.getState().composers.get(HOME)?.text).toBe("fix the failing build /triage ");
});
});
describe("New Task composer Home / End", () => {
const os = usePlatform.getState().os;
afterEach(() => {
usePlatform.setState({ os });
vi.restoreAllMocks();
});
it("moves the caret to the line's start or end on macOS, extending the selection with Shift", () => {
usePlatform.setState({ os: "macos" });
const modify = vi.fn();
vi.spyOn(window, "getSelection").mockReturnValue({ modify } as unknown as Selection);
render(<HomeEditor />);
const box = screen.getByRole("textbox");
expect(fireEvent.keyDown(box, { key: "Home" })).toBe(false);
fireEvent.keyDown(box, { key: "End", shiftKey: true });
fireEvent.keyDown(box, { key: "End", metaKey: true });
expect(modify.mock.calls).toEqual([
["move", "backward", "lineboundary"],
["extend", "forward", "lineboundary"],
]);
});
it("leaves Home / End to the text box elsewhere", () => {
usePlatform.setState({ os: "windows" });
render(<HomeEditor />);
expect(fireEvent.keyDown(screen.getByRole("textbox"), { key: "Home" })).toBe(true);
});
});
describe("New Task composer paste", () => {
it("attaches the clipboard's image when WebKitGTK pastes an empty clipboardData", async () => {
render(<HomeEditor />);
fireEvent.paste(screen.getByRole("textbox"), {
clipboardData: { types: [], files: [] },
});
fireEvent.paste(screen.getByRole("textbox"), { clipboardData: { types: [], files: [] } });
await waitFor(() =>
expect(composerDraft(HOME).images).toEqual([{ mimeType: "image/png", data: "iVBORw0KGgo=", name: "image" }]),
);
@@ -289,9 +284,7 @@ describe("New Task composer paste", () => {
it("leaves a text paste to the text box", () => {
render(<HomeEditor />);
fireEvent.paste(screen.getByRole("textbox"), {
clipboardData: { types: ["text/plain"], files: [] },
});
fireEvent.paste(screen.getByRole("textbox"), { clipboardData: { types: ["text/plain"], files: [] } });
expect(clipboardImage).not.toHaveBeenCalled();
});
});
@@ -315,11 +308,7 @@ describe("New Task composer OS file drop", () => {
expect(composerDraft(HOME)).toEqual({
text: "",
files: [
{
path: "/Users/me/Desktop/recording one.mov",
rel: "recording one.mov",
attached: true,
},
{ path: "/Users/me/Desktop/recording one.mov", rel: "recording one.mov", attached: true },
{ path: "/Users/me/Desktop/huge.png", rel: "huge.png", attached: true },
],
images: [{ mimeType: "image/png", data: "iVBORw0KGgo=", name: "shot.png" }],
@@ -338,11 +327,7 @@ describe("workspace task project", () => {
name: "Workspace",
trusted: true,
isGit: false,
workspace: {
version: 1,
name: "Workspace",
projects: ["/project-a", "/project-b"],
},
workspace: { version: 1, name: "Workspace", projects: ["/project-a", "/project-b"] },
} as ProjectInfo;
it("defaults to the active member, last used member, then first member", () => {
expect(defaultTaskProject(project, "/project-b/src/main.ts", "/project-a")).toBe("/project-b");
@@ -354,13 +339,7 @@ describe("workspace task project", () => {
vi.mocked(isTauri).mockReturnValue(false);
useProject.setState({ project });
agentCommands.mockResolvedValue({ status: "ok", data: [] });
useAgents.setState({
draft: {
...useAgents.getState().draft,
projectRoot: "/project-b",
isolation,
},
});
useAgents.setState({ draft: { ...useAgents.getState().draft, projectRoot: "/project-b", isolation } });
render(<HomeEditor />);
const selector = screen.getByRole("button", { name: "Project" });
expect(selector).toHaveTextContent("project-b");
@@ -369,14 +348,11 @@ describe("workspace task project", () => {
fireEvent.click(selector);
expect(within(screen.getByRole("listbox")).getByText("/project-a")).toBeInTheDocument();
fireEvent.click(screen.getByRole("option", { name: /project-a/ }));
typeIn(screen.getByRole("textbox"), "Update member");
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Update member" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Start" })).toBeEnabled());
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
await waitFor(() => expect(agentCreate).toHaveBeenCalled());
expect(agentCreate.mock.calls[0]![0]).toMatchObject({
projectRoot: "/project-a",
isolation,
});
expect(agentCreate.mock.calls[0]![0]).toMatchObject({ projectRoot: "/project-a", isolation });
expect(vcsBranchInfo).toHaveBeenLastCalledWith("/project-a");
expect(vcsBranches).toHaveBeenLastCalledWith("/project-a");
await vi.dynamicImportSettled();
-245
View File
@@ -1,245 +0,0 @@
/**
* The composer's CodeMirror extensions. The text stays plain (`@src/a.rs:10-20`, `/triage`,
* `> quote`) and is what's stored and sent; references to files of the draft are drawn as
* chips over their text, commands tagged, and quote lines as a block without their `>`.
*
* A chip moves and deletes as one piece. Backspace right after one turns it back into its
* text to edit (the completion opens on it); it folds again once the caret leaves it.
*
* A ``` block (pasted code or terminal output) is a card while the caret is outside it; a click
* on the card, or the caret at its end, opens it as text.
*/
import { EditorState, StateEffect, StateField, type Extension, type Range, type Text } from "@codemirror/state";
import { Decoration, EditorView, WidgetType, keymap, type DecorationSet } from "@codemirror/view";
import { fencedBlocks, isQuoteLine, messageTokens, parseMention, type FileRef, type Mention } from "./composerModel";
import { chipElement, chipStyles, codeCardElement, openReference } from "./ReferenceChip";
/** The draft's referenced files: mentions of these are chips. */
export const setReferences = StateEffect.define<readonly FileRef[]>();
const references = StateField.define<readonly FileRef[]>({
create: () => [],
update(value, tr) {
for (const e of tr.effects) if (e.is(setReferences)) return e.value;
return value;
},
});
interface Chip {
from: number;
to: number;
path: string;
mention: Mention;
}
/** `@` and `/` tokens of the line around `pos`, in document positions. */
function lineTokens(doc: Text, pos: number) {
const line = doc.lineAt(pos);
const out: {
kind: "file" | "command";
from: number;
to: number;
value: string;
}[] = [];
let offset = line.from;
for (const token of messageTokens(line.text)) {
if (token.kind !== "text")
out.push({
kind: token.kind,
from: offset,
to: offset + token.value.length,
value: token.value,
});
offset += token.value.length;
}
return out;
}
const unfold = StateEffect.define<number>();
/** Start of the chip being edited as text, while the caret stays in it. */
const unfolded = StateField.define<number | null>({
create: () => null,
update(value, tr) {
for (const e of tr.effects) if (e.is(unfold)) return e.value;
if (value === null) return null;
const start = tr.changes.mapPos(value, 1);
const head = tr.state.selection.main.head;
const token = lineTokens(tr.state.doc, start).find((t) => t.from === start && t.kind === "file");
return token && head > token.from && head <= token.to ? start : null;
},
});
class ChipWidget extends WidgetType {
constructor(
readonly path: string,
readonly mention: Mention,
readonly text: string,
) {
super();
}
eq(other: ChipWidget) {
return other.path === this.path && other.text === this.text;
}
toDOM() {
const chip = chipElement(this.path, this.mention);
// Keep the editor's focus and caret; a click opens the file.
chip.addEventListener("mousedown", (e) => e.preventDefault());
chip.addEventListener("click", (e) => {
e.stopPropagation();
openReference(this.path, this.mention);
});
return chip;
}
ignoreEvent() {
return true;
}
}
class CodeCardWidget extends WidgetType {
constructor(
readonly info: string,
readonly body: string,
) {
super();
}
eq(other: CodeCardWidget) {
return other.info === this.info && other.body === this.body;
}
toDOM(view: EditorView) {
const range = (dom: HTMLElement) => {
const from = view.posAtDOM(dom);
return view.state.field(painted).cards.find((c) => c.from === from) ?? null;
};
const card: HTMLElement = codeCardElement(
this.info,
this.body,
() => {
const at = range(card);
if (at) view.dispatch({ selection: { anchor: at.to } });
view.focus();
},
() => {
const at = range(card);
if (!at) return;
const end = view.state.sliceDoc(at.to, at.to + 1) === "\n" ? at.to + 1 : at.to;
view.dispatch({ changes: { from: at.from, to: end }, userEvent: "delete" });
view.focus();
},
);
return card;
}
ignoreEvent() {
return true;
}
}
const quoteMarker = Decoration.replace({});
const codeLine = Decoration.line({ class: chipStyles.codeLine });
interface Painted {
decorations: DecorationSet;
atomic: DecorationSet;
chips: readonly Chip[];
cards: readonly { from: number; to: number }[];
}
function paint(state: EditorState): Painted {
const refs = state.field(references);
const editing = state.field(unfolded);
const decorations: Range<Decoration>[] = [];
const atomic: Range<Decoration>[] = [];
const chips: Chip[] = [];
const cards: { from: number; to: number }[] = [];
const { doc } = state;
const head = state.selection.main.head;
// Line numbers (1-based) inside ``` blocks: no chips or quotes there.
const coded = new Set<number>();
for (const fence of fencedBlocks(doc.toString().split("\n"))) {
const from = doc.line(fence.open + 1).from;
const to = doc.line(fence.close + 1).to;
for (let n = fence.open + 1; n <= fence.close + 1; n++) coded.add(n);
if (head > from && head <= to) {
for (let n = fence.open + 1; n <= fence.close + 1; n++) decorations.push(codeLine.range(doc.line(n).from));
continue;
}
const body =
fence.close > fence.open + 1 ? doc.sliceString(doc.line(fence.open + 2).from, doc.line(fence.close).to) : "";
const card = Decoration.replace({ widget: new CodeCardWidget(fence.info, body), block: true });
decorations.push(card.range(from, to));
atomic.push(card.range(from, to));
cards.push({ from, to });
}
for (let n = 1; n <= doc.lines; n++) {
if (coded.has(n)) continue;
const line = doc.line(n);
if (isQuoteLine(line.text)) {
const prev = n > 1 && isQuoteLine(doc.line(n - 1).text);
const next = n < doc.lines && isQuoteLine(doc.line(n + 1).text);
const cls = [chipStyles.quoteLine, !prev && chipStyles.quoteFirst, !next && chipStyles.quoteLast];
decorations.push(Decoration.line({ class: cls.filter(Boolean).join(" ") }).range(line.from));
const marker = line.text.startsWith("> ") ? 2 : 1;
decorations.push(quoteMarker.range(line.from, line.from + marker));
atomic.push(quoteMarker.range(line.from, line.from + marker));
}
for (const token of lineTokens(doc, line.from)) {
if (token.kind === "command") {
decorations.push(Decoration.mark({ class: chipStyles.command }).range(token.from, token.to));
continue;
}
const mention = parseMention(token.value);
const ref = mention && refs.find((f) => f.rel === mention.rel);
if (!mention || !ref || editing === token.from) {
decorations.push(Decoration.mark({ class: chipStyles.pending }).range(token.from, token.to));
continue;
}
const chip = Decoration.replace({
widget: new ChipWidget(ref.path, mention, token.value),
});
decorations.push(chip.range(token.from, token.to));
atomic.push(chip.range(token.from, token.to));
chips.push({ from: token.from, to: token.to, path: ref.path, mention });
}
}
return {
decorations: Decoration.set(decorations, true),
atomic: Decoration.set(atomic, true),
chips,
cards,
};
}
const painted = StateField.define<Painted>({
create: paint,
update(value, tr) {
// A ``` block opens while the caret is in it.
const fences = tr.selection && tr.state.doc.toString().includes("```");
const changed =
tr.docChanged ||
fences ||
tr.effects.some((e) => e.is(setReferences) || e.is(unfold)) ||
tr.startState.field(unfolded) !== tr.state.field(unfolded);
return changed ? paint(tr.state) : value;
},
provide: (f) => [
EditorView.decorations.from(f, (p) => p.decorations),
EditorView.atomicRanges.of((view) => view.state.field(f).atomic),
],
});
/** Backspace right after a chip turns it back into its text. */
const unfoldOnBackspace = keymap.of([
{
key: "Backspace",
run(view) {
const sel = view.state.selection.main;
if (!sel.empty) return false;
const chip = view.state.field(painted).chips.find((c) => c.to === sel.head);
if (!chip) return false;
view.dispatch({ effects: unfold.of(chip.from), userEvent: "unfold" });
return true;
},
},
]);
export function referenceExtensions(): Extension {
return [references, unfolded, painted, unfoldOnBackspace];
}
+93 -337
View File
@@ -1,19 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { copySourceOf, noteCopySource } from "@/platform/clipboardSource";
import { EditorView } from "@codemirror/view";
import { trackEditorCopies } from "./references";
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { caretOf, setCaret, textOf, typeChars, typeIn } from "./composerTesting";
import chipStyles from "./ReferenceChip.module.css";
import { FILES_DRAG, dragSession } from "@/ui";
const findFiles = vi.fn();
const findDirs = vi.fn();
const pathCompletions = vi.fn();
const fsProbe = vi.fn();
const fsEntryKind = vi.fn();
const findSymbols = vi.fn();
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
@@ -25,16 +17,11 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
findFiles: (query: string, limit: number) => findFiles(query, limit),
findDirs: (query: string, limit: number) => findDirs(query, limit),
pathCompletions: (base: string, typed: string) => pathCompletions(base, typed),
fsProbe: (path: string) => fsProbe(path),
fsEntryKind: (path: string) => fsEntryKind(path),
findSymbols: (query: string, filter: string, limit: number) => findSymbols(query, filter, limit),
},
};
});
import { Composer } from "./Composer";
import { registerAgentContextSource } from "@/workbench/contributions";
import { useEditorRegistry, type TextEditorHandle } from "@/editor/api";
import { composerDraft, updateComposer } from "./store";
import { emptyDraft, toPrompt } from "./composerModel";
@@ -54,33 +41,15 @@ function renderComposer(root = "/p") {
onSubmit={() => {}}
/>,
);
return screen.getByRole("textbox", { name: "Message" });
return screen.getByPlaceholderText("Ask") as HTMLTextAreaElement;
}
beforeEach(() => {
updateComposer(ID, emptyDraft());
const match = (path: string, relPath: string, score: number) => ({
path,
relPath,
score,
indices: [],
});
const match = (path: string, relPath: string, score: number) => ({ path, relPath, score, indices: [] });
findFiles.mockReset().mockReturnValue(ok([match("/p/src/agents.ts", "src/agents.ts", 40)]));
findDirs.mockReset().mockReturnValue(ok([match("/p/src/agents", "src/agents/", 90)]));
pathCompletions.mockReset().mockReturnValue(ok([{ name: "lib", dir: true }]));
fsEntryKind
.mockReset()
.mockImplementation((path: string) =>
path.startsWith("/p/")
? ok(path.endsWith(".ts") ? "file" : "dir")
: Promise.resolve({ status: "error", error: { kind: "notFound", message: "" } }),
);
fsProbe.mockReset().mockReturnValue(
Promise.resolve({
status: "error",
error: { kind: "notFound", message: "" },
}),
);
});
describe("Composer dropped paths", () => {
@@ -104,10 +73,7 @@ describe("Composer dropped paths", () => {
try {
act(() => {
// Folders carry a trailing slash, as the Project view marks them.
const drag = dragSession({
type: FILES_DRAG,
payload: "/p/src/a.ts\n/p/src/dir/",
});
const drag = dragSession({ type: FILES_DRAG, payload: "/p/src/a.ts\n/p/src/dir/" });
expect(drag.drop(0, 0)).toBe(true);
});
} finally {
@@ -117,24 +83,24 @@ describe("Composer dropped paths", () => {
{ path: "/p/src/a.ts", rel: "src/a.ts" },
{ path: "/p/src/dir", rel: "src/dir" },
]);
expect(textOf(box)).toBe("@src/a.ts @src/dir ");
expect(box.value).toBe("@src/a.ts @src/dir ");
});
it("deduplicates live references and allows a deleted reference to be dropped again", () => {
const box = renderComposer();
drop(box, "/p/src/a.ts\n/p/src/a.ts\n/p/src/dir/");
drop(box, "/p/src/a.ts\n/p/src/dir/");
expect(textOf(box)).toBe("@src/a.ts @src/dir ");
typeIn(box, "@src/dir ");
expect(box.value).toBe("@src/a.ts @src/dir ");
fireEvent.change(box, { target: { value: "@src/dir " } });
drop(box, "/p/src/a.ts");
expect(textOf(box)).toBe("@src/dir @src/a.ts ");
expect(box.value).toBe("@src/dir @src/a.ts ");
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/dir", "/p/src/a.ts"]);
});
it("maps project files and folders into the task worktree, rejecting external paths", () => {
const box = renderComposer("/worktree");
drop(box, "/p/src/a.ts\n/p/src/dir/\n/external/a.ts\n/p-other/a.ts\n/p/../external/b.ts\nrelative.ts");
expect(textOf(box)).toBe("@src/a.ts @src/dir ");
expect(box.value).toBe("@src/a.ts @src/dir ");
expect(toPrompt(composerDraft(ID)).files).toEqual(["/worktree/src/a.ts", "/worktree/src/dir"]);
});
});
@@ -144,349 +110,139 @@ describe("Composer @ folders", () => {
findFiles.mockReturnValue(ok([]));
findDirs.mockReturnValue(ok([]));
const box = renderComposer();
typeChars(box, "@");
await userEvent.type(box, "@");
expect(await screen.findByRole("option")).toHaveTextContent("lib/");
expect(pathCompletions).toHaveBeenCalledWith("/p/_", "");
fireEvent.keyDown(box, { key: "Enter" });
expect(textOf(box)).toBe("@lib/ ");
await userEvent.keyboard("{Enter}");
expect(box.value).toBe("@lib/ ");
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/lib"]);
});
it("lists index folders among files and references one on Enter", async () => {
const box = renderComposer();
typeChars(box, "see @agen");
await userEvent.type(box, "see @agen");
const options = await screen.findAllByRole("option");
expect(options.map((o) => o.textContent)).toEqual(["agents/src", "agents.tssrc"]);
fireEvent.keyDown(box, { key: "Enter" });
expect(textOf(box)).toBe("see @src/agents/ ");
expect(options.map((o) => o.textContent)).toEqual(["agents/src/agents", "agents.tssrc/agents.ts"]);
await userEvent.keyboard("{Enter}");
expect(box.value).toBe("see @src/agents/ ");
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/agents"]);
});
it("highlights the matched characters of a file name", async () => {
findFiles.mockReturnValue(
ok([
{
path: "/p/src/agents.ts",
relPath: "src/agents.ts",
score: 40,
indices: [4, 5, 6],
},
]),
);
findDirs.mockReturnValue(ok([]));
const box = renderComposer();
typeChars(box, "@age");
const option = await screen.findByRole("option");
expect([...option.querySelectorAll("mark")].map((m) => m.textContent)).toEqual(["age"]);
});
it("keeps completing inside a folder on Tab, which drops the folder once a file is picked", async () => {
const box = renderComposer();
typeChars(box, "@agen");
await userEvent.type(box, "@agen");
await screen.findAllByRole("option");
fireEvent.keyDown(box, { key: "Tab" });
expect(textOf(box)).toBe("@src/agents/");
await userEvent.keyboard("{Tab}");
expect(box.value).toBe("@src/agents/");
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/agents"]);
typeChars(box, "x.ts ");
await userEvent.type(box, "x.ts ");
expect(toPrompt(composerDraft(ID)).files).toEqual([]);
});
it("references a folder of a typed path", async () => {
const box = renderComposer();
typeChars(box, "@./");
await userEvent.type(box, "@./");
await screen.findByRole("option");
fireEvent.keyDown(box, { key: "Enter" });
expect(textOf(box)).toBe("@./lib/ ");
await userEvent.keyboard("{Enter}");
expect(box.value).toBe("@./lib/ ");
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/lib"]);
});
});
describe("Composer reference chips", () => {
const chips = () => [...document.querySelectorAll<HTMLElement>("[data-ref-path]")];
it("shows a referenced file as a chip with its name and lines, and leaves the text as written", () => {
describe("Composer tag editing", () => {
it.each(["@src/ag", "/rev"])("deletes %s character by character while it is typed", async (tag) => {
const box = renderComposer();
act(() => updateComposer(ID, { files: [{ path: "/p/src/a.ts", rel: "src/a.ts" }] }));
typeIn(box, "see @src/a.ts:10-20 and @other.ts ");
expect(chips().map((c) => [c.dataset.refPath, c.textContent])).toEqual([["/p/src/a.ts", "a.ts10–20"]]);
expect(chips()[0]).toHaveAccessibleName("File reference a.ts, lines 10–20");
// Not referenced: marked as typed.
expect(screen.getByText("@other.ts")).toHaveClass(chipStyles.pending!);
expect(textOf(box)).toBe("see @src/a.ts:10-20 and @other.ts ");
const user = userEvent.setup();
await user.type(box, `see ${tag}`);
await user.keyboard("{Backspace}");
expect(box.value).toBe(`see ${tag.slice(0, -1)}`);
await user.keyboard("{Backspace}");
expect(box.value).toBe(`see ${tag.slice(0, -2)}`);
});
it("turns a chip back into its text on Backspace, and folds it again when the caret leaves", async () => {
it("deletes inside a folder entered with Tab character by character", async () => {
const box = renderComposer();
typeChars(box, "see @agen");
const user = userEvent.setup();
await user.type(box, "@agen");
await screen.findAllByRole("option");
fireEvent.keyDown(box, { key: "Enter" });
expect(textOf(box)).toBe("see @src/agents/ ");
expect(chips()).toHaveLength(1);
fireEvent.keyDown(box, { key: "Backspace" });
expect(textOf(box)).toBe("see @src/agents/");
expect(chips()).toHaveLength(1);
// Right after the chip: it opens as text, and the completion offers it again.
fireEvent.keyDown(box, { key: "Backspace" });
expect(textOf(box)).toBe("see @src/agents/");
expect(chips()).toHaveLength(0);
fireEvent.keyDown(box, { key: "Backspace" });
expect(textOf(box)).toBe("see @src/agents");
expect(chips()).toHaveLength(0);
typeChars(box, "/");
setCaret(box, 0);
expect(chips()).toHaveLength(1);
await user.keyboard("{Tab}");
expect(box.value).toBe("@src/agents/");
await user.keyboard("{Backspace}");
expect(box.value).toBe("@src/agents");
});
it("moves over and deletes a chip as one piece", () => {
it("edits a completed reference character by character after its space", async () => {
const box = renderComposer();
act(() => updateComposer(ID, { files: [{ path: "/p/src/a.ts", rel: "src/a.ts" }] }));
typeIn(box, "see @src/a.ts next", 4);
fireEvent.keyDown(box, { key: "Delete" });
expect(textOf(box)).toBe("see next");
const user = userEvent.setup();
await user.type(box, "see @agen");
await screen.findAllByRole("option");
await user.keyboard("{Enter}");
expect(box.value).toBe("see @src/agents/ ");
await user.keyboard("{Backspace}{Backspace}");
expect(box.value).toBe("see @src/agents");
});
it("deletes a reference that names no file character by character", () => {
it.each(["@src/a.ts", "/review"])("edits %s after moving away and returning", async (tag) => {
const box = renderComposer();
typeIn(box, "see @src/a.ts next", 13);
fireEvent.keyDown(box, { key: "Backspace" });
expect(textOf(box)).toBe("see @src/a.t next");
expect(caretOf(box)).toBe(12);
const user = userEvent.setup();
fireEvent.change(box, { target: { value: `see ${tag} next` } });
box.focus();
box.setSelectionRange(0, 0);
fireEvent.select(box);
box.blur();
box.focus();
box.setSelectionRange(4 + tag.length, 4 + tag.length);
fireEvent.select(box);
await user.keyboard("{Backspace}");
expect(box.value).toBe(`see ${tag.slice(0, -1)} next`);
expect(box.selectionStart).toBe(3 + tag.length);
});
});
describe("Composer quotes", () => {
it("draws quote lines as a block without their marker", () => {
it.each(["@src/a.ts", "/review"])("moves within %s with the arrow keys", async (tag) => {
const box = renderComposer();
typeIn(box, "> first\n> second\n\nreply");
const lines = [...box.querySelectorAll(".cm-line")];
expect(lines.map((l) => l.textContent)).toEqual(["first", "second", "", "reply"]);
expect(lines.map((l) => l.classList.contains(chipStyles.quoteLine!))).toEqual([true, true, false, false]);
expect(textOf(box)).toBe("> first\n> second\n\nreply");
const user = userEvent.setup();
fireEvent.change(box, { target: { value: `see ${tag} next` } });
box.focus();
box.setSelectionRange(4, 4);
await user.keyboard("{ArrowRight}");
expect(box.selectionStart).toBe(5);
await user.keyboard("{ArrowLeft}");
expect(box.selectionStart).toBe(4);
await user.keyboard("{Delete}");
expect(box.value).toBe(`see ${tag.slice(1)} next`);
});
});
describe("Composer editing", () => {
it("offers no completion while the input method composes", () => {
it.each(["@src/a.ts", "/review"])("selects and deletes only part of %s", async (tag) => {
const box = renderComposer();
const user = userEvent.setup();
fireEvent.change(box, { target: { value: `see ${tag} next` } });
box.focus();
box.setSelectionRange(5, 7);
fireEvent.select(box);
await user.keyboard("{Backspace}");
expect(box.value).toBe(`see ${tag.slice(0, 1)}${tag.slice(3)} next`);
});
it("leaves IME keys and composition text to the input method", () => {
const box = renderComposer();
fireEvent.change(box, { target: { value: "@src/a.ts" } });
const before = box.value;
box.setSelectionRange(before.length, before.length);
expect(fireEvent.keyDown(box, { key: "Backspace", isComposing: true })).toBe(true);
expect(fireEvent.keyDown(box, { key: "Backspace", keyCode: 229 })).toBe(true);
fireEvent.compositionStart(box);
typeIn(box, "@中文");
fireEvent.change(box, { target: { value: "@中文" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(fireEvent.keyDown(box, { key: "Enter" })).toBe(true);
fireEvent.compositionEnd(box);
expect(textOf(box)).toBe("@中文");
expect(box.value).toBe("@中文");
});
it("pastes plain text as text", async () => {
it("pastes plain text through the native textarea", async () => {
const box = renderComposer();
const user = userEvent.setup();
act(() => box.focus());
box.focus();
await user.paste("look @src/a.ts\nthen /review please");
expect(textOf(box)).toBe("look @src/a.ts\nthen /review please");
});
it("references a pasted path of a project file, with its lines", async () => {
const box = renderComposer();
const user = userEvent.setup();
act(() => box.focus());
await user.paste("/p/src/a.ts:3-5");
await vi.waitFor(() => expect(textOf(box)).toBe("@src/a.ts:3-5 "));
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/a.ts"]);
await user.paste("/elsewhere/b.ts");
expect(textOf(box)).toBe("@src/a.ts:3-5 /elsewhere/b.ts");
});
it("references pasted folders, several paths and file URLs", async () => {
const box = renderComposer();
const user = userEvent.setup();
act(() => box.focus());
await user.paste("/p/src/dir\n'/p/src/b.ts'\nfile:///p/src/c.ts");
await vi.waitFor(() => expect(textOf(box)).toBe("@src/dir/ @src/b.ts @src/c.ts "));
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/dir", "/p/src/b.ts", "/p/src/c.ts"]);
});
it("pastes a path that is gone, or holds a space, as text", async () => {
const box = renderComposer();
const user = userEvent.setup();
act(() => box.focus());
await user.paste("/p/my dir/a.ts");
expect(textOf(box)).toBe("/p/my dir/a.ts");
fsEntryKind.mockReturnValue(Promise.resolve({ status: "error", error: { kind: "notFound", message: "" } }));
await user.paste(" /p/gone.ts");
await vi.waitFor(() => expect(textOf(box)).toBe("/p/my dir/a.ts /p/gone.ts"));
});
});
describe("Composer current file", () => {
it("offers the focused editor's file until the draft references it", () => {
const view = {} as TextEditorHandle["view"];
act(() =>
useEditorRegistry.setState({ active: { view, ctx: { path: "/p/src/main.ts" } as TextEditorHandle["ctx"] } }),
);
try {
renderComposer();
const offer = screen.getByRole("button", { name: /main\.ts/ });
expect(offer).toHaveTextContent("Current File");
fireEvent.click(offer);
expect(composerDraft(ID).text).toBe("@src/main.ts ");
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/main.ts"]);
expect(screen.queryByRole("button", { name: /main\.ts/ })).toBeNull();
expect(document.querySelector("[data-ref-path]")).toHaveAttribute("data-ref-path", "/p/src/main.ts");
} finally {
act(() => useEditorRegistry.setState({ active: null }));
}
});
});
describe("Composer pasted code and terminal output", () => {
const paste = (box: HTMLElement, text: string) =>
act(() => {
const event = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent;
Object.defineProperty(event, "clipboardData", {
value: { files: [], types: ["text/plain"], getData: () => text },
});
box.dispatchEvent(event);
});
it("turns terminal output into a terminal card, and opens it as text when the caret enters it", () => {
const box = renderComposer();
typeIn(box, "why does this fail?");
noteCopySource("$ pnpm test\nFAIL a.test.ts\n expected 1", { kind: "terminal", title: "zsh" });
paste(box, "$ pnpm test\nFAIL a.test.ts\n expected 1\n");
expect(textOf(box)).toBe("why does this fail?\n```terminal zsh\n$ pnpm test\nFAIL a.test.ts\n expected 1\n```\n");
const card = screen.getByRole("group", { name: "Terminal · zsh" });
expect(card).toHaveTextContent("3 lines");
expect(card.querySelector("pre")).toHaveTextContent("$ pnpm test");
fireEvent.click(card);
expect(screen.queryByRole("group")).toBeNull();
expect(box.querySelectorAll(`.${chipStyles.codeLine}`)).toHaveLength(5);
});
it("removes a card with its button", () => {
const box = renderComposer();
typeIn(box, "```ts\nconst a = 1;\n```\nnext");
fireEvent.click(within(screen.getByRole("group", { name: "TypeScript" })).getByRole("button", { name: "Remove" }));
expect(textOf(box)).toBe("next");
});
it("references the lines copied from an editor, and pastes a copied single line as text", () => {
const box = renderComposer();
noteCopySource("fn a() {\n}", { kind: "file", path: "/p/src/a.rs", from: 3, to: 4 });
paste(box, "fn a() {\n}");
expect(textOf(box)).toBe("@src/a.rs:3-4 ");
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/a.rs"]);
noteCopySource("fn a()", { kind: "file", path: "/p/src/a.rs", from: 3, to: 3 });
paste(box, "fn a()");
expect(textOf(box)).toBe("@src/a.rs:3-4 fn a()");
});
it("pastes a stack trace as a card, its project lines as references after it", async () => {
const box = renderComposer();
const trace = "TypeError: boom\n at run (/p/src/a.ts:12:5)\n at main (/elsewhere/x.ts:3:1)";
paste(box, trace);
expect(screen.getByRole("group", { name: "Stack Trace" })).toHaveTextContent("3 lines");
await vi.waitFor(() => expect(textOf(box)).toBe(`\`\`\`trace\n${trace}\n\`\`\`\n@src/a.ts:12 `));
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/a.ts"]);
});
it("folds long pasted text into a card", () => {
const box = renderComposer();
const long = Array.from({ length: 40 }, (_, i) => `row ${i}`).join("\n");
paste(box, long);
expect(screen.getByRole("group", { name: "Pasted Text" })).toHaveTextContent("40 lines");
expect(textOf(box)).toBe(`\`\`\`text\n${long}\n\`\`\`\n`);
});
it("pastes other text as written", () => {
const box = renderComposer();
noteCopySource("something else", { kind: "terminal", title: "zsh" });
paste(box, "line one\nline two");
expect(textOf(box)).toBe("line one\nline two");
});
});
describe("editor copies", () => {
it("notes the copied lines of a file editor, or the caret's line when nothing is selected", () => {
const view = new EditorView({
doc: "one\ntwo\nthree\nfour",
parent: document.body,
selection: { anchor: 4, head: 14 },
});
const handle = { view, ctx: { path: "/p/src/a.ts" } } as TextEditorHandle;
act(() => useEditorRegistry.setState({ editors: new Set([handle]) }));
const stop = trackEditorCopies();
try {
view.contentDOM.dispatchEvent(new Event("copy", { bubbles: true }));
expect(copySourceOf("two\nthree\n")).toEqual({ kind: "file", path: "/p/src/a.ts", from: 2, to: 3 });
view.dispatch({ selection: { anchor: 15 } });
view.contentDOM.dispatchEvent(new Event("copy", { bubbles: true }));
expect(copySourceOf("four")).toEqual({ kind: "file", path: "/p/src/a.ts", from: 4, to: 4 });
} finally {
stop();
view.destroy();
act(() => useEditorRegistry.setState({ editors: new Set() }));
}
});
});
describe("Composer @ context and symbols", () => {
it("offers what features contribute above the files and inserts it", async () => {
const off = [
registerAgentContextSource({
id: "problems",
title: "Problems",
description: "The project's errors and warnings",
icon: "problems",
resolve: async () => ({ kind: "block", info: "problems", text: "src/a.ts:3:1: error: nope" }),
}),
registerAgentContextSource({
id: "changes",
title: "Changes",
description: "Files with uncommitted changes",
icon: "diff",
resolve: async () => ({ kind: "files", paths: ["/p/src/a.ts", "/p/src/b.ts", "/elsewhere/c.ts"] }),
}),
];
try {
const box = renderComposer();
typeChars(box, "fix @pro");
const options = await screen.findAllByRole("option");
expect(options[0]).toHaveTextContent("@problems");
fireEvent.keyDown(box, { key: "Enter" });
await vi.waitFor(() =>
expect(textOf(box)).toBe("fix\n```problems\nsrc/a.ts:3:1: error: nope\n```\n@src/a.ts:3 "),
);
expect(screen.getByRole("group", { name: "Problems" })).toBeInTheDocument();
typeChars(box, "@chan");
await screen.findAllByRole("option");
fireEvent.keyDown(box, { key: "Enter" });
await vi.waitFor(() => expect(textOf(box)).toMatch(/@src\/a\.ts:3 @src\/a\.ts @src\/b\.ts $/));
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/a.ts", "/p/src/b.ts"]);
} finally {
off.forEach((d) => d());
}
});
it("references a symbol's file and line with @#", async () => {
findSymbols.mockReturnValue(
ok([
{
name: "fetchUser",
kind: "function",
container: "client",
path: "/p/src/api/client.ts",
relPath: "src/api/client.ts",
range: { start: { line: 11, col: 0 }, end: { line: 11, col: 9 } },
score: 10,
indices: [0, 1, 2],
},
]),
);
const box = renderComposer();
typeChars(box, "@#fet");
const option = await screen.findByRole("option");
expect(option).toHaveTextContent("fetchUserclient · src/api/client.ts:12");
expect(findSymbols).toHaveBeenCalledWith("fet", "all", 12);
fireEvent.keyDown(box, { key: "Enter" });
expect(textOf(box)).toBe("@src/api/client.ts:12 ");
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/api/client.ts"]);
expect(box.value).toBe("look @src/a.ts\nthen /review please");
});
});
+12 -201
View File
@@ -27,26 +27,19 @@ export interface ComposerDraft {
images: AgentImage[];
}
export const emptyDraft = (): ComposerDraft => ({
text: "",
files: [],
images: [],
});
export const emptyDraft = (): ComposerDraft => ({ text: "", files: [], images: [] });
export type MessageToken = { kind: "text" | "file" | "command"; value: string };
/**
* A message's text split into plain runs, `@file` references and `/command` skills. A
* command is a `/word` at the start or after whitespace; a reference likewise after `@`.
* Punctuation that ends either belongs to the text.
*/
export function messageTokens(text: string): MessageToken[] {
const out: MessageToken[] = [];
let last = 0;
// Full-width punctuation ends one too, as Chinese text runs on without a space after it.
for (const match of text.matchAll(/(^|\s)([@/][^\s@,。;:!?、()【】《》「」『』]+)/g)) {
// Punctuation after it ends the sentence, not the path: "see @a.ts, then /review."
const token = match[2]!.replace(/^(.+?)[,.;:!?)\]}>'",。;:!?、)】》」』]+$/, "$1");
for (const match of text.matchAll(/(^|\s)([@/][^\s@]+)/g)) {
const token = match[2]!;
const start = match.index! + match[1]!.length;
const command = /^\/[A-Za-z][\w:.-]*$/.test(token);
if (token.startsWith("/") && !command) continue;
@@ -68,12 +61,7 @@ export function completionAt(text: string, caret: number): Completion | null {
const word = /(^|\s)([@/])([^\s@]*)$/.exec(before);
if (!word) return null;
const from = caret - word[3]!.length - 1;
return {
kind: word[2] === "/" ? "command" : "file",
query: word[3]!,
from,
to: caret,
};
return { kind: word[2] === "/" ? "command" : "file", query: word[3]!, from, to: caret };
}
/** Replaces the completion with `insert` plus a space; returns the text and the caret after it. */
@@ -131,8 +119,6 @@ interface IndexMatch {
path: string;
relPath: string;
score: number;
/** Matched characters of `relPath`. */
indices?: number[];
}
/**
@@ -143,170 +129,15 @@ export function mergeMatches(
files: readonly IndexMatch[],
dirs: readonly IndexMatch[],
limit: number,
): {
path: string;
rel: string;
dir: boolean;
score: number;
indices: number[];
}[] {
): { path: string; rel: string; dir: boolean; score: number }[] {
return [
...files.map((f) => ({
path: f.path,
rel: f.relPath,
dir: false,
score: f.score,
indices: f.indices ?? [],
})),
...dirs.map((d) => ({
path: d.path,
rel: d.relPath.replace(/\/$/, ""),
dir: true,
score: d.score,
indices: d.indices ?? [],
})),
...files.map((f) => ({ path: f.path, rel: f.relPath, dir: false, score: f.score })),
...dirs.map((d) => ({ path: d.path, rel: d.relPath.replace(/\/$/, ""), dir: true, score: d.score })),
]
.sort((a, b) => b.score - a.score || Number(a.dir) - Number(b.dir))
.slice(0, limit);
}
export interface Mention {
/** As written after `@`; a folder keeps its trailing `/`. */
rel: string;
dir: boolean;
/** 1-based lines, when the mention names a range. */
lines?: { from: number; to: number };
}
/** `@src/a.rs:10-20` → its path and lines; `null` for an empty `@`. */
export function parseMention(token: string): Mention | null {
const m = /^@(.+?)(?::(\d+)(?:-(\d+))?)?$/.exec(token);
if (!m) return null;
const rel = m[1]!;
const from = m[2] ? Number(m[2]) : undefined;
const lines = from === undefined ? undefined : { from, to: m[3] ? Math.max(from, Number(m[3])) : from };
return { rel, dir: rel.endsWith("/"), ...(lines && { lines }) };
}
/** "10" or "10–20". */
export const linesLabel = (lines: { from: number; to: number }) =>
lines.from === lines.to ? String(lines.from) : `${lines.from}–${lines.to}`;
/** Lines of `text` that start with `>`: a quoted reply or selection, drawn as a block. */
export const isQuoteLine = (line: string) => /^>( |$)/.test(line);
export interface Fence {
/** Line indexes (0-based) of the opening and the closing fence. */
open: number;
close: number;
/** After the opening fence: a language, or `terminal <title>`. */
info: string;
}
/** The closed ``` blocks among `lines`. */
export function fencedBlocks(lines: readonly string[]): Fence[] {
const out: Fence[] = [];
for (let i = 0; i < lines.length; i++) {
const open = /^(`{3,})([^`]*)$/.exec(lines[i]!);
if (!open) continue;
const ticks = open[1]!.length;
const close = lines.findIndex((l, j) => j > i && new RegExp(`^\`{${ticks},}\\s*$`).test(l));
if (close < 0) break;
out.push({ open: i, close, info: open[2]!.trim() });
i = close;
}
return out;
}
/** `text` as a ``` block on its own lines, fenced longer than any run of backticks in it. */
export function fenced(text: string, info: string): string {
const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
const fence = "`".repeat(longest + 1);
return `${fence}${info}\n${text.replace(/\r\n?/g, "\n").replace(/\s+$/, "")}\n${fence}`;
}
export type FenceKind = "terminal" | "problems" | "trace" | "text" | "code";
/**
* A block's kind and label: `terminal zsh` → a terminal named zsh, `problems`, `trace` (an
* error's stack), `text` (a long paste); anything else is code in that language (`ts`).
*/
export function fenceLabel(info: string): { kind: FenceKind; label: string; language: string | null } {
const [first = "", ...rest] = info.split(/\s+/);
if (first === "terminal" || first === "problems" || first === "trace" || first === "text")
return { kind: first, label: rest.join(" "), language: null };
return { kind: "code", label: info, language: first || null };
}
/** An error's stack trace or a crash: pasted as a block, its project lines as references. */
export function looksLikeTrace(text: string): boolean {
if (!text.trim().includes("\n")) return false;
return [
/^\s+at\s+\S/m, // JavaScript, Java, C#
/^Traceback \(most recent call last\)/m,
/^\s*File ".+", line \d+/m,
/panicked at /,
/^goroutine \d+ \[/m,
/^\s*\d+: .+\n\s+at \S+:\d+/m, // Rust backtrace
/^(?:Uncaught |Unhandled )?[\w.$]*(?:Error|Exception)\b.*\n\s+(?:at|File|in) /m,
].some((re) => re.test(text));
}
/** Long pasted text goes in as a block rather than filling the box. */
export const LONG_PASTE_LINES = 30;
/**
* `path:line` places in output (`src/a.ts:12`, `/abs/a.go:34:5`, `(a.js:3:9)`,
* `File "a.py", line 7`), each once, at most `limit`, dependency paths left out.
*/
export function placesInText(text: string, limit = 8): { path: string; line: number }[] {
const out: { path: string; line: number }[] = [];
const seen = new Set<string>();
const add = (raw: string, line: string) => {
const path = raw.replace(/^file:\/\//, "");
if (/(^|[\\/])(node_modules|site-packages|\.cargo|\.rustup|go[\\/]pkg[\\/]mod)[\\/]/.test(path)) return;
const key = `${path}:${line}`;
if (seen.has(key) || out.length >= limit) return;
seen.add(key);
out.push({ path, line: Number(line) });
};
for (const m of text.matchAll(/File "([^"]+)", line (\d+)/g)) add(m[1]!, m[2]!);
for (const m of text.matchAll(/(?:file:\/\/)?((?:[A-Za-z]:)?[\w.@+\-\\/]*[\w-]\.[A-Za-z0-9]{1,8}):(\d+)(?::\d+)?/g))
add(m[1]!, m[2]!);
return out;
}
/**
* Pasted absolute paths, one per line (`/abs/a.rs`, `/abs/a.rs:10-20`, `/abs/a.rs:10:5`,
* quoted, or `file://` URLs); `null` when any line is something else.
*/
export function pastedPaths(text: string): { path: string; lines?: { from: number; to: number } }[] | null {
const rows = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (!rows.length) return null;
const out: { path: string; lines?: { from: number; to: number } }[] = [];
for (let row of rows) {
row = row.replace(/^(["'])(.*)\1$/, "$2");
if (row.startsWith("file://")) {
try {
row = decodeURIComponent(new URL(row).pathname).replace(/^\/([A-Za-z]:)/, "$1");
} catch {
return null;
}
}
const m = /^((?:\/|[A-Za-z]:[\\/]).*?)(?::(\d+)(?:(?:-(\d+))|(?::\d+))?)?$/.exec(row);
if (!m) return null;
const from = m[2] ? Number(m[2]) : undefined;
out.push({
path: m[1]!.replace(/[\\/]+$/, "") || m[1]!,
...(from !== undefined && { lines: { from, to: m[3] ? Math.max(from, Number(m[3])) : from } }),
});
}
return out;
}
/** `@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}`;
@@ -325,11 +156,7 @@ export function toPrompt(draft: ComposerDraft): AgentPrompt {
seen.add(f.path);
return true;
});
return {
text: draft.text.trim(),
files: files.map((f) => f.path),
images: draft.images,
};
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 || d.files.some((f) => f.attached);
@@ -428,11 +255,7 @@ export function retryParts(r: AgentRetry, now: number): { attempt: string; wait:
}
/** "167k/1M (17%)" of the context window and a tooltip with the exact counts. */
export function usageLabel(u: AgentUsage): {
short: string;
percent: number;
detail: string;
} {
export function usageLabel(u: AgentUsage): { short: string; percent: number; detail: string } {
const percent = Math.min(100, Math.round((u.used / u.size) * 100));
return {
short: `${tokens(u.used)}/${tokens(u.size)} (${percent}%)`,
@@ -455,18 +278,9 @@ export function limitLabel(l: AgentLimit, now: number): { short: string; percent
const percent = Math.min(100, Math.round(l.usedPercent ?? 0));
const name = known ? tx(known.name) : l.window;
const used = tx`${name}: ${percent}% used`;
if (l.resetsMs == null)
return {
short: `${known?.short ?? l.window} ${percent}%`,
percent,
detail: used,
};
if (l.resetsMs == null) return { short: `${known?.short ?? l.window} ${percent}%`, percent, detail: used };
const reset = untilReset(Math.max(0, l.resetsMs - now));
return {
short: `${known?.short ?? l.window} ${percent}%`,
percent,
detail: tx`${used}, resets in ${reset}`,
};
return { short: `${known?.short ?? l.window} ${percent}%`, percent, detail: tx`${used}, resets in ${reset}` };
}
/** `45m`, `2h 51m`, `5d 3h`. */
@@ -481,10 +295,7 @@ function untilReset(ms: number): string {
/** "$0.42": what the session has cost so far. */
export function costLabel(c: AgentCost): string {
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: c.currency,
}).format(c.amount ?? 0);
return new Intl.NumberFormat("en-US", { style: "currency", currency: c.currency }).format(c.amount ?? 0);
} catch {
// Not an ISO 4217 code.
return `${(c.amount ?? 0).toFixed(2)} ${c.currency}`;
-53
View File
@@ -1,53 +0,0 @@
/** Test helpers for the composer's editor (a CodeMirror view); plain text areas work too. */
import { act, fireEvent } from "@testing-library/react";
import { EditorView } from "@codemirror/view";
function viewOf(box: HTMLElement): EditorView {
const view = EditorView.findFromDOM(box);
if (!view) throw new Error("not a composer editor");
return view;
}
/** Replaces the composer's text as typing would, caret at `caret` (the end by default). */
export function typeIn(box: HTMLElement, value: string, caret = value.length) {
if (box instanceof HTMLTextAreaElement) {
fireEvent.change(box, {
target: { value, selectionStart: caret, selectionEnd: caret },
});
return;
}
const view = viewOf(box);
act(() => {
view.focus();
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
selection: { anchor: caret },
userEvent: "input.type",
});
});
}
/** Types `chars` at the caret, one at a time. */
export function typeChars(box: HTMLElement, chars: string) {
const view = viewOf(box);
act(() => view.focus());
for (const ch of chars)
act(() => {
view.dispatch(view.state.replaceSelection(ch), {
userEvent: "input.type",
});
});
}
export const textOf = (box: HTMLElement) =>
box instanceof HTMLTextAreaElement ? box.value : viewOf(box).state.doc.toString();
export const caretOf = (box: HTMLElement) => viewOf(box).state.selection.main.head;
export function setCaret(box: HTMLElement, anchor: number, head = anchor) {
const view = viewOf(box);
act(() => {
view.focus();
view.dispatch({ selection: { anchor, head } });
});
}
+1 -14
View File
@@ -9,9 +9,7 @@ import { useProject } from "@/platform/project";
import { executeAction } from "@/platform/actions";
import { lazyComponent } from "@/ui";
import { isVirtualUri } from "@/platform/virtualDocs";
import { call, commands } from "@/platform/ipc";
import {
registerAgentContextSource,
registerEditorDropHandler,
registerEditorKind,
registerMenuGroup,
@@ -32,7 +30,7 @@ import {
TOOL_WINDOW,
providerIcon,
} from "./model";
import { sendPaths, sendSelection, trackEditorCopies } from "./references";
import { sendPaths, sendSelection } from "./references";
import { focusedSession } from "./sessionFocus";
import { closeActiveTask, closeAndNewTask, connect, disconnect, openHome, refreshProviders, useAgents } from "./store";
import { TaskStatusMark } from "./TaskStatusMark";
@@ -79,17 +77,6 @@ export function activate(): () => void {
sync(useProject.getState().project?.root);
const disposers = [
activateSettingsPages(),
trackEditorCopies(),
registerAgentContextSource({
id: "changes",
title: "Changes",
description: "Files with uncommitted changes",
icon: "diff",
resolve: async () => {
const changed = await call(commands.vcsStatus());
return changed.length ? { kind: "files", paths: changed.map((f) => f.path) } : null;
},
}),
// The performance harness (`perf/`): long conversations to switch between and scroll.
import.meta.env.DEV || import.meta.env.MODE === "perf"
? registerActions([
+29 -119
View File
@@ -3,22 +3,10 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event";
import type { AgentEntry, AgentTask } from "@/bindings";
const ipc = vi.hoisted(() => ({
rollback: vi.fn(),
prompt: vi.fn(),
cancel: vi.fn(),
confirm: vi.fn(),
}));
const ipc = vi.hoisted(() => ({ rollback: vi.fn(), prompt: vi.fn(), cancel: vi.fn(), confirm: vi.fn() }));
vi.mock("@/workbench/messages", () => ({ confirm: ipc.confirm }));
const entries: AgentEntry[] = [
{
type: "user",
text: "Original message",
attachments: [],
sentMs: 1,
doneMs: 2,
steered: false,
},
{ type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false },
{ type: "agent", text: "Original answer" },
];
@@ -27,10 +15,7 @@ vi.mock("@/platform/ipc", async (original) => ({
commands: {
agentEntries: async () => ({ status: "ok", data: entries }),
agentChanges: async () => ({ status: "ok", data: [] }),
agentGroupedChanges: async () => ({
status: "ok",
data: { uncommitted: [], commits: [] },
}),
agentGroupedChanges: async () => ({ status: "ok", data: { uncommitted: [], commits: [] } }),
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
agentRollback: ipc.rollback,
agentPrompt: ipc.prompt,
@@ -38,13 +23,10 @@ vi.mock("@/platform/ipc", async (original) => ({
},
}));
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({
onDragDropEvent: () => Promise.resolve(() => {}),
}),
getCurrentWebview: () => ({ onDragDropEvent: () => Promise.resolve(() => {}) }),
}));
import { SessionEditor } from "./SessionEditor";
import { textOf, typeIn } from "./composerTesting";
import { registerActions } from "@/platform/actions";
import { composerDraft, retractFrom, rerunFrom, sendMessage, updateComposer, useAgents } from "./store";
import { replyActions } from "./replyActions";
@@ -110,9 +92,7 @@ it("keeps the transcript and running session when a fresh-session rerun is cance
describe("retracting a turn", () => {
it.each([0, 1])("stops before rollback and restores text/images without sending (turn %s)", async (turn) => {
const image = { mimeType: "image/png", data: "AAA", name: "shot.png" };
useAgents.setState({
tasks: new Map([[task.id, { ...task, status: "running" }]]),
});
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
updateComposer(task.id, { text: "Draft" });
const running = retractFrom(task.id, turn, "Original", [image]);
await waitFor(() => expect(ipc.cancel).toHaveBeenCalledTimes(1));
@@ -121,20 +101,14 @@ describe("retracting a turn", () => {
await running;
expect(ipc.rollback).toHaveBeenCalledExactlyOnceWith(task.id, turn);
expect(ipc.prompt).not.toHaveBeenCalled();
expect(composerDraft(task.id)).toEqual({
text: "Original\nDraft",
images: [image],
files: [],
});
expect(composerDraft(task.id)).toEqual({ text: "Original\nDraft", images: [image], files: [] });
expect(useAgents.getState().focus?.key).toBe(task.id);
});
it("keeps the draft and avoids rollback when the agent does not stop in time", async () => {
vi.useFakeTimers();
try {
useAgents.setState({
tasks: new Map([[task.id, { ...task, status: "running" }]]),
});
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
const result = expect(retractFrom(task.id, 1, "Original")).rejects.toThrow("The agent is still stopping");
await vi.advanceTimersByTimeAsync(15_000);
await result;
@@ -146,10 +120,7 @@ describe("retracting a turn", () => {
});
it("recovers the original prompt when rollback fails", async () => {
ipc.rollback.mockResolvedValue({
status: "error",
error: { kind: "internal", message: "Rollback failed" },
});
ipc.rollback.mockResolvedValue({ status: "error", error: { kind: "internal", message: "Rollback failed" } });
await expect(retractFrom(task.id, 1, "Original")).rejects.toThrow("Rollback failed");
expect(composerDraft(task.id).text).toBe("Original");
expect(ipc.prompt).not.toHaveBeenCalled();
@@ -166,9 +137,7 @@ describe("retracting a turn", () => {
);
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
const user = userEvent.setup();
const retract = screen.getAllByRole("button", {
name: "Retract Message",
})[0]!;
const retract = screen.getAllByRole("button", { name: "Retract Message" })[0]!;
await user.click(retract);
await user.click(retract);
expect(ipc.confirm).toHaveBeenCalledTimes(1);
@@ -187,12 +156,8 @@ async function edit() {
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const box = screen.getAllByRole("textbox", { name: "Message" })[0]!;
typeIn(box, "Edited message");
return {
user,
box,
send: screen.getAllByRole("button", { name: "Send" })[0]!,
};
fireEvent.change(box, { target: { value: "Edited message" } });
return { user, box, send: screen.getAllByRole("button", { name: "Send" })[0]! };
}
it.each([false, true])("recovers images after failed regeneration (new draft: %s)", async (draft) => {
@@ -203,13 +168,7 @@ it.each([false, true])("recovers images after failed regeneration (new draft: %s
[
task.id,
[
{
type: "user",
text: "Original message",
attachments: [image.name],
images: [image],
steered: false,
},
{ type: "user", text: "Original message", attachments: [image.name], images: [image], steered: false },
entries[1]!,
],
],
@@ -226,16 +185,8 @@ it.each([false, true])("recovers images after failed regeneration (new draft: %s
.run({ ctx: {}, arg: { task: task.id, turn: 0 } });
await waitFor(() => expect(ipc.prompt).toHaveBeenCalledTimes(1));
const files = draft ? [{ path: "/project/a.ts", rel: "a.ts" }] : [];
if (draft)
updateComposer(task.id, {
text: "Another draft @a.ts",
files,
images: [added],
});
finish({
status: "error",
error: { kind: "internal", message: "Send failed" },
});
if (draft) updateComposer(task.id, { text: "Another draft @a.ts", files, images: [added] });
finish({ status: "error", error: { kind: "internal", message: "Send failed" } });
await running;
const expected = {
text: draft ? "Original message\nAnother draft @a.ts" : "Original message",
@@ -251,14 +202,7 @@ it.each([false, true])("recovers images after failed regeneration (new draft: %s
describe("a sent message with images", () => {
afterEach(() => {
entries[0] = {
type: "user",
text: "Original message",
attachments: [],
sentMs: 1,
doneMs: 2,
steered: false,
};
entries[0] = { type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false };
});
it("shows the images and lists only the files as chips", async () => {
@@ -276,14 +220,7 @@ describe("a sent message with images", () => {
expect(img).toHaveAttribute("src", "data:image/png;base64,AAA");
expect(screen.getByText("a.ts")).toBeInTheDocument();
expect(screen.queryByText("shot.png")).not.toBeInTheDocument();
entries[0] = {
type: "user",
text: "Original message",
attachments: [],
sentMs: 1,
doneMs: 2,
steered: false,
};
entries[0] = { type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false };
});
it("opens an image in a dialog and keeps the images when editing, minus the removed ones", async () => {
@@ -313,14 +250,7 @@ describe("a sent message with images", () => {
await user.click(screen.getAllByRole("button", { name: "Send" })[0]!);
await waitFor(() => expect(ipc.prompt).toHaveBeenCalledTimes(1));
expect(ipc.prompt.mock.calls[0]![1].images).toEqual([{ mimeType: "image/png", data: "BBB", name: "b.png" }]);
entries[0] = {
type: "user",
text: "Original message",
attachments: [],
sentMs: 1,
doneMs: 2,
steered: false,
};
entries[0] = { type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false };
});
});
@@ -330,7 +260,7 @@ describe("editing a sent agent message", () => {
expect(send.textContent).toBe("");
expect(send).toHaveAttribute("data-tooltip", "Send");
expect(send).toBeEnabled();
typeIn(box, "");
fireEvent.change(box, { target: { value: "" } });
expect(send).toBeDisabled();
});
@@ -341,18 +271,15 @@ describe("editing a sent agent message", () => {
fireEvent.keyDown(box, { key: "Enter", metaKey: true, isComposing: true });
fireEvent.keyDown(box, { key: "Enter", metaKey: true, keyCode: 229 });
expect(ipc.rollback).not.toHaveBeenCalled();
expect(textOf(box)).toBe("Edited message");
expect(box).toHaveValue("Edited message");
});
it("keeps the draft and shows a rollback failure beside it", async () => {
ipc.rollback.mockResolvedValue({
status: "error",
error: { kind: "internal", message: "Rollback failed" },
});
ipc.rollback.mockResolvedValue({ status: "error", error: { kind: "internal", message: "Rollback failed" } });
const { user, box, send } = await edit();
await user.click(send);
expect(await screen.findByRole("alert")).toHaveTextContent("Rollback failed");
expect(textOf(box)).toBe("Edited message");
expect(box).toHaveValue("Edited message");
expect(box).toBeInTheDocument();
expect(send).toBeEnabled();
expect(ipc.prompt).not.toHaveBeenCalled();
@@ -401,13 +328,8 @@ describe("editing a sent agent message", () => {
act(() => updateComposer(task.id, { text: draft }));
await user.click(send);
expect(box).not.toBeInTheDocument();
await act(async () =>
finish({
status: "error",
error: { kind: "internal", message: "Send failed" },
}),
);
expect(textOf(screen.getByRole("textbox", { name: "Message" }))).toBe(
await act(async () => finish({ status: "error", error: { kind: "internal", message: "Send failed" } }));
expect(screen.getByRole("textbox", { name: "Message" })).toHaveValue(
draft ? `Edited message\n${draft}` : "Edited message",
);
expect(screen.getByRole("textbox", { name: "Message" })).toHaveFocus();
@@ -424,9 +346,7 @@ describe("editing a sent agent message", () => {
});
it("waits for a running turn to stop before rolling it back", async () => {
useAgents.setState({
tasks: new Map([[task.id, { ...task, status: "running" }]]),
});
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
const { user, send } = await edit();
await user.click(send);
expect(ipc.cancel).toHaveBeenCalledWith(task.id, null);
@@ -447,7 +367,7 @@ describe("editing a sent agent message", () => {
expect(ipc.rollback).not.toHaveBeenCalled();
expect(ipc.prompt).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Edit" }));
expect(textOf(screen.getAllByRole("textbox", { name: "Message" })[0]!)).toBe("Original message");
expect(screen.getAllByRole("textbox", { name: "Message" })[0]).toHaveValue("Original message");
},
);
});
@@ -456,34 +376,24 @@ describe("session composer submission", () => {
it.each(["click", "Enter"])("keeps an accessible icon-only Send action with %s submission", async (method) => {
let finish!: (value: unknown) => void;
ipc.prompt.mockReturnValue(new Promise((resolve) => (finish = resolve)));
render(
<SessionEditor
input={{
kind: "agent-session",
key: task.id,
data: { task: task.id },
}}
/>,
);
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
const send = screen.getByRole("button", { name: "Send" });
expect(send.textContent).toBe("");
expect(send).toHaveAttribute("data-tooltip", "Send");
expect(send).toBeDisabled();
const box = screen.getByRole("textbox", { name: "Message" });
typeIn(box, "Follow up");
fireEvent.change(box, { target: { value: "Follow up" } });
expect(send).toBeEnabled();
if (method === "click") fireEvent.click(send);
else fireEvent.keyDown(box, { key: "Enter" });
expect(ipc.prompt).toHaveBeenCalledExactlyOnceWith(task.id, { text: "Follow up", files: [], images: [] }, false);
expect(send).toBeDisabled();
await act(async () => finish({ status: "ok", data: null }));
expect(textOf(box)).toBe("");
expect(box).toHaveValue("");
});
it("keeps Queue named and shows icon-only Stop actions while the agent works", async () => {
useAgents.setState({
tasks: new Map([[task.id, { ...task, status: "running" }]]),
});
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
const queue = screen.getByRole("button", { name: "Queue" });
expect(queue.textContent).toBe("");
+1 -24
View File
@@ -1,6 +1,5 @@
/** "Send to Agent": the editor selection or Project view files as `@` references. */
import { activeEditor, useEditorRegistry } from "@/editor/api";
import { noteCopySource } from "@/platform/clipboardSource";
import { activeEditor } from "@/editor/api";
import { useProject } from "@/platform/project";
import { isVirtualUri } from "@/platform/virtualDocs";
import { mentionOf } from "./composerModel";
@@ -35,25 +34,3 @@ export function sendPaths(paths: readonly string[]): boolean {
if (refs.length) addReferences(refs);
return refs.length > 0;
}
/**
* Notes the lines an editor's copy takes (the whole line when nothing is selected), so pasting
* them into the composer references the file's lines.
*/
export function trackEditorCopies(): () => void {
const onCopy = (e: ClipboardEvent) => {
const handle = [...useEditorRegistry.getState().editors].find((h) =>
h.view.contentDOM.contains(e.target as Node | null),
);
if (!handle || isVirtualUri(handle.ctx.path)) return;
const { state } = handle.view;
const sel = state.selection.main;
const first = state.doc.lineAt(sel.from);
// A selection ending at a line start does not include that line.
const last = state.doc.lineAt(sel.to > sel.from && state.doc.lineAt(sel.to).from === sel.to ? sel.to - 1 : sel.to);
const text = sel.empty ? first.text : state.sliceDoc(sel.from, sel.to);
noteCopySource(text, { kind: "file", path: handle.ctx.path, from: first.number, to: last.number });
};
document.addEventListener("copy", onCopy, true);
return () => document.removeEventListener("copy", onCopy, true);
}
+94 -457
View File
@@ -15,8 +15,7 @@ import { taskActions } from "./taskActions";
import { replyActions } from "./replyActions";
import { focusedSession } from "./sessionFocus";
import styles from "./Session.module.css";
import chipStyles from "./ReferenceChip.module.css";
import { textOf, typeIn } from "./composerTesting";
import tagStyles from "@/ui/InlineTag.module.css";
const backend = vi.hoisted(() => ({
entries: new Map<string, AgentEntry[]>(),
@@ -49,19 +48,12 @@ const backend = vi.hoisted(() => ({
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
commands: {
fsProbe: async () => ({
status: "ok",
data: { size: 1, binary: false, tooLarge: false },
}),
agentEntries: async (id: string, sub: string | null) => {
const key = sub ? `${id}/${sub}` : id;
backend.fetched.push(key);
await backend.entryWait;
if (backend.entryError !== null)
return {
status: "error",
error: { kind: "unavailable", message: backend.entryError },
};
return { status: "error", error: { kind: "unavailable", message: backend.entryError } };
return { status: "ok", data: backend.entries.get(key) ?? [] };
},
agentChanges: async () => {
@@ -93,22 +85,12 @@ vi.mock("@/platform/ipc", async (original) => ({
backend.imported.push([provider, sessionId]);
const found = backend.external.find((e) => e.provider === provider && e.sessionId === sessionId);
if (!found) throw new Error("unknown session");
return {
status: "ok",
data: {
...task("imported"),
id: "imported",
title: found.title,
provider,
},
};
return { status: "ok", data: { ...task("imported"), id: "imported", title: found.title, provider } };
},
},
}));
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({
onDragDropEvent: () => Promise.resolve(() => {}),
}),
getCurrentWebview: () => ({ onDragDropEvent: () => Promise.resolve(() => {}) }),
}));
vi.mock("@/workbench/messages", async (original) => ({
...(await original<typeof import("@/workbench/messages")>()),
@@ -157,11 +139,7 @@ const subagent: SubagentEntry = {
endedMs: 1,
cancellable: false,
};
const input = (id: string) => ({
kind: SESSION_KIND,
key: sessionKey(id),
data: { task: id },
});
const input = (id: string) => ({ kind: SESSION_KIND, key: sessionKey(id), data: { task: id } });
const stream = () => document.querySelector<HTMLDivElement>(`.${styles.stream}`)!;
const tool = (id: string, overrides: Partial<Extract<AgentEntry, { type: "tool" }>> = {}): AgentEntry => ({
type: "tool",
@@ -204,10 +182,7 @@ beforeEach(() => {
backend.imported = [];
backend.externalError = null;
backend.accepted = [];
backend.acceptedOutcome = {
accepted: ["very-long-file-name.ts"],
skipped: [],
};
backend.acceptedOutcome = { accepted: ["very-long-file-name.ts"], skipped: [] };
backend.confirmed = [];
backend.confirmAnswer = true;
backend.entries = new Map([
@@ -228,9 +203,7 @@ beforeEach(() => {
describe("conversation rows", () => {
it("keeps authors above messages and their folded work with matching icon-only actions", async () => {
useProject.setState({
settings: { agents: { foldTurns: false } } as never,
});
useProject.setState({ settings: { agents: { foldTurns: false } } as never });
const user = (text: string): AgentEntry => ({
type: "user",
text,
@@ -256,12 +229,8 @@ describe("conversation rows", () => {
const author = stream().querySelectorAll(`.${styles.agentAuthor}`)[1]!;
expect(author.nextElementSibling).toHaveClass(styles.processRun!);
expect(author.parentElement?.nextElementSibling?.firstElementChild).toHaveClass(styles.agentMessage!);
const quote = within(stream()).getAllByRole("button", {
name: "Quote Reply",
});
const copy = within(stream()).getAllByRole("button", {
name: "Copy Markdown",
});
const quote = within(stream()).getAllByRole("button", { name: "Quote Reply" });
const copy = within(stream()).getAllByRole("button", { name: "Copy Markdown" });
// One per turn: only a turn's final reply carries actions.
expect(quote).toHaveLength(2);
for (const button of [...quote, ...copy]) {
@@ -281,18 +250,8 @@ describe("conversation rows", () => {
tool("two"),
{ type: "agent", text: "More detail" },
tool("List .", { status: "running" }),
{
type: "permission",
id: 1,
title: "Run python3",
command: "python3",
options: [],
chosen,
},
{
type: "plan",
items: [{ content: "Check files", status: "in_progress" }],
},
{ type: "permission", id: 1, title: "Run python3", command: "python3", options: [], chosen },
{ type: "plan", items: [{ content: "Check files", status: "in_progress" }] },
{ type: "thought", text: "Consider the result" },
{ type: "agent", text: "Done" },
]);
@@ -325,18 +284,9 @@ describe("conversation rows", () => {
);
it("puts the time and hover actions under a turn's final reply only", async () => {
useProject.setState({
settings: { agents: { foldTurns: false } } as never,
});
useProject.setState({ settings: { agents: { foldTurns: false } } as never });
backend.entries.set("A", [
{
type: "user",
text: "Question",
attachments: [],
steered: false,
sentMs: 1000,
doneMs: 13000,
},
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: 13000 },
{ type: "agent", text: "First reply" },
tool("read"),
{ type: "agent", text: "Final reply" },
@@ -399,14 +349,7 @@ describe("process folding", () => {
describe("turn folding", () => {
it("folds a finished turn's work under its message and keeps the reply", async () => {
backend.entries.set("A", [
{
type: "user",
text: "Question",
attachments: [],
steered: false,
sentMs: 1000,
doneMs: 13000,
},
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: 13000 },
{ type: "thought", text: "think" },
tool("one"),
{ type: "agent", text: "Interim note" },
@@ -428,14 +371,7 @@ describe("turn folding", () => {
it("leaves the turn the agent works on open", async () => {
backend.entries.set("A", [
{
type: "user",
text: "Question",
attachments: [],
steered: false,
sentMs: 1000,
doneMs: null,
},
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: null },
tool("one"),
{ type: "agent", text: "Interim note" },
]);
@@ -468,10 +404,7 @@ describe("opened runs", () => {
doneMs: null,
} as const;
const setTask = (status: AgentTask["status"]) => ({
tasks: new Map(useAgents.getState().tasks).set("A", {
...useAgents.getState().tasks.get("A")!,
status,
}),
tasks: new Map(useAgents.getState().tasks).set("A", { ...useAgents.getState().tasks.get("A")!, status }),
});
useAgents.setState({
entries: new Map([["A", [asked, tool("one"), tool("two"), { type: "agent", text: "Interim" }] as AgentEntry[]]]),
@@ -525,33 +458,8 @@ describe("folded content", () => {
});
});
describe("message code blocks", () => {
it("shows a terminal block as a card, folding long output", async () => {
const output = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n");
backend.entries.set("A", [
{
type: "user",
text: `why?\n\`\`\`terminal zsh\n${output}\n\`\`\`\nthanks`,
attachments: [],
images: [],
sentMs: 1,
doneMs: 2,
steered: false,
},
]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const card = await within(stream()).findByRole("group", { name: "Terminal · zsh" });
expect(card).toHaveTextContent("20 lines");
expect(card.querySelector("pre")!.textContent!.split("\n")).toHaveLength(12);
fireEvent.click(within(card).getByRole("button", { name: "Show 8 more lines" }));
expect(card.querySelector("pre")!.textContent!.split("\n")).toHaveLength(20);
expect(within(stream()).getByText(/thanks/)).toBeInTheDocument();
});
});
describe("message tags", () => {
it("shows file references as chips that open the file, and skills as tags", async () => {
it("shows file references and skills as distinct tags", async () => {
backend.entries.set("A", [
{
type: "user",
@@ -565,14 +473,8 @@ describe("message tags", () => {
]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const chip = await within(stream()).findByRole("button", {
name: "File reference a.ts",
});
expect(chip).toHaveTextContent("a.ts");
expect(chip).toHaveAttribute("data-ref-path", "/project/src/a.ts");
expect(within(stream()).getByText("/triage")).toHaveClass(chipStyles.command!);
// Mentioned in the text, so not listed again under it.
expect(within(stream()).getAllByText("a.ts")).toHaveLength(1);
expect(await screen.findByText("@src/a.ts")).toHaveClass(tagStyles.tag!);
expect(screen.getByText("/triage")).toHaveClass(tagStyles.accent!);
});
});
@@ -612,14 +514,8 @@ describe("scrolling", () => {
render(<SessionEditor input={input("A")} />);
const el = stream();
let height = 1000;
Object.defineProperty(el, "scrollHeight", {
configurable: true,
get: () => height,
});
Object.defineProperty(el, "clientHeight", {
configurable: true,
value: 400,
});
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
el.scrollTop = 600;
fireEvent.scroll(el);
fireEvent.wheel(el, { deltaY: -200 });
@@ -627,42 +523,16 @@ describe("scrolling", () => {
fireEvent.scroll(el);
expect(screen.getByRole("button", { name: "Jump to Latest" })).toBeInTheDocument();
height = 1100;
const sent = {
type: "user",
text: "Next",
attachments: [],
steered: false,
sentMs: 1,
doneMs: null,
} as AgentEntry;
const sent = { type: "user", text: "Next", attachments: [], steered: false, sentMs: 1, doneMs: null } as AgentEntry;
act(() => useAgents.setState({ entries: new Map([["A", [reply, sent]]]) }));
expect(el.scrollTop).toBe(1100);
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
});
it.each([
{
name: "stays at the end below it",
top: 300,
bottom: 360,
scrolled: 60,
away: false,
},
{
name: "keeps its header on screen",
top: 50,
bottom: 900,
scrolled: 50,
away: true,
},
{
name: "back at the end without following",
top: 300,
bottom: 360,
scrolled: 60,
away: false,
released: true,
},
{ name: "stays at the end below it", top: 300, bottom: 360, scrolled: 60, away: false },
{ name: "keeps its header on screen", top: 50, bottom: 900, scrolled: 50, away: true },
{ name: "back at the end without following", top: 300, bottom: 360, scrolled: 60, away: false, released: true },
])("scrolls a section opened at the end into view: $name", async ({ top, bottom, scrolled, away, released }) => {
backend.entries.set("A", [
{ type: "thought", text: "Weighing it" },
@@ -672,14 +542,8 @@ describe("scrolling", () => {
render(<SessionEditor input={input("A")} />);
await screen.findByText("Answer A");
const el = stream();
Object.defineProperty(el, "scrollHeight", {
configurable: true,
value: 1000,
});
Object.defineProperty(el, "clientHeight", {
configurable: true,
value: 400,
});
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
el.getBoundingClientRect = () => ({ top: 0, bottom: 400 }) as DOMRect;
el.scrollTop = 600;
fireEvent.scroll(el);
@@ -711,21 +575,13 @@ describe("scrolling", () => {
});
it("keeps following when the view grows taller and the browser pulls the offset back", () => {
useAgents.setState({
entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]),
});
useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]) });
render(<SessionEditor input={input("A")} />);
const el = stream();
let height = 1000;
let viewport = 400;
Object.defineProperty(el, "scrollHeight", {
configurable: true,
get: () => height,
});
Object.defineProperty(el, "clientHeight", {
configurable: true,
get: () => viewport,
});
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
Object.defineProperty(el, "clientHeight", { configurable: true, get: () => viewport });
el.scrollTop = 600;
fireEvent.scroll(el);
// The composer shrank after sending: the end moved up and the offset was clamped to it.
@@ -734,31 +590,19 @@ describe("scrolling", () => {
fireEvent.scroll(el);
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
height = 1200;
act(() =>
useAgents.setState({
entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]),
}),
);
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]) }));
expect(el.scrollTop).toBe(1200);
});
it.each(["wheel", "touch", "ArrowUp", "PageUp", "Home", "Shift+Space"])(
"releases following before an upward %s scroll, even within the bottom threshold",
async (method) => {
useAgents.setState({
entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]),
});
useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]) });
render(<SessionEditor input={input("A")} />);
const el = stream();
let height = 1000;
Object.defineProperty(el, "scrollHeight", {
configurable: true,
get: () => height,
});
Object.defineProperty(el, "clientHeight", {
configurable: true,
value: 400,
});
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
el.scrollTop = 600;
fireEvent.scroll(el);
if (method === "wheel") fireEvent.wheel(el, { deltaY: -4 });
@@ -770,11 +614,7 @@ describe("scrolling", () => {
if (method === "Shift+Space") fireEvent.keyDown(el, { key: " ", shiftKey: true });
// A chunk can land before the browser dispatches the scroll event.
height = 1004;
act(() =>
useAgents.setState({
entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]),
}),
);
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]) }));
expect(el.scrollTop).toBe(600);
el.scrollTop = 588;
fireEvent.scroll(el);
@@ -783,11 +623,7 @@ describe("scrolling", () => {
el.scrollTop = 592;
fireEvent.scroll(el);
height = 1008;
act(() =>
useAgents.setState({
entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]),
}),
);
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]) }));
expect(el.scrollTop).toBe(592);
expect(screen.getByRole("button", { name: "Jump to Latest" })).toBeInTheDocument();
// Scrolling down to the end resumes following.
@@ -795,49 +631,29 @@ describe("scrolling", () => {
el.scrollTop = 608;
fireEvent.scroll(el);
height = 1100;
act(() =>
useAgents.setState({
entries: new Map([["A", [{ type: "agent", text: "Latest" }]]]),
}),
);
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Latest" }]]]) }));
expect(el.scrollTop).toBe(1100);
},
);
it("does not move the viewport while the scrollbar is held during streaming", () => {
useAgents.setState({
entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]),
});
useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Initial reply" }]]]) });
render(<SessionEditor input={input("A")} />);
const el = stream();
let height = 1000;
Object.defineProperty(el, "scrollHeight", {
configurable: true,
get: () => height,
});
Object.defineProperty(el, "clientHeight", {
configurable: true,
value: 400,
});
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
el.scrollTop = 600;
fireEvent.scroll(el);
fireEvent.pointerDown(el);
height = 1100;
act(() =>
useAgents.setState({
entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]),
}),
);
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "Streaming" }]]]) }));
expect(el.scrollTop).toBe(600);
el.scrollTop = 588;
fireEvent.scroll(el);
fireEvent.pointerUp(window);
height = 1200;
act(() =>
useAgents.setState({
entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]),
}),
);
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "More streaming" }]]]) }));
expect(el.scrollTop).toBe(588);
});
@@ -848,25 +664,15 @@ describe("scrolling", () => {
const el = stream();
const output = screen.getByText("Tool output");
let height = 1000;
Object.defineProperty(el, "scrollHeight", {
configurable: true,
get: () => height,
});
Object.defineProperty(el, "clientHeight", {
configurable: true,
value: 400,
});
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
output.style.overflowY = "auto";
output.scrollTop = 100;
el.scrollTop = 600;
fireEvent.scroll(el);
fireEvent.wheel(output, { deltaY: -4 });
height = 1100;
act(() =>
useAgents.setState({
entries: new Map([["A", [...transcript, { type: "agent", text: "Streaming" }]]]),
}),
);
act(() => useAgents.setState({ entries: new Map([["A", [...transcript, { type: "agent", text: "Streaming" }]]]) }));
expect(el.scrollTop).toBe(1100);
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
});
@@ -875,14 +681,8 @@ describe("scrolling", () => {
render(<SessionEditor input={input("A")} />);
const el = stream();
let height = 1000;
Object.defineProperty(el, "scrollHeight", {
configurable: true,
get: () => height,
});
Object.defineProperty(el, "clientHeight", {
configurable: true,
value: 400,
});
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
fireEvent.wheel(el);
el.scrollTop = 100;
fireEvent.scroll(el);
@@ -891,11 +691,7 @@ describe("scrolling", () => {
act(() => useAgents.setState({ entries: new Map([["A", []]]) }));
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
height = 1400;
act(() =>
useAgents.setState({
entries: new Map([["A", [{ type: "agent", text: "New reply" }]]]),
}),
);
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "New reply" }]]]) }));
expect(el.scrollTop).toBe(1400);
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
});
@@ -910,14 +706,8 @@ describe("scrolling", () => {
</EditorShownProvider>,
);
const el = stream();
Object.defineProperty(el, "scrollHeight", {
configurable: true,
value: 3000,
});
Object.defineProperty(el, "clientHeight", {
configurable: true,
value: 400,
});
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 3000 });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
// Give the conversation a new tail while the scrollbar is held.
fireEvent.pointerDown(el);
act(() =>
@@ -948,14 +738,8 @@ describe("scrolling", () => {
expect(await screen.findByText("Answer A")).toBeInTheDocument();
const el = stream();
let height = 1000;
Object.defineProperty(el, "scrollHeight", {
configurable: true,
get: () => height,
});
Object.defineProperty(el, "clientHeight", {
configurable: true,
value: 400,
});
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
el.scrollTop = 600;
fireEvent.scroll(el);
// The conversation grew after it was scrolled to the end, or WebKit moved the offset back
@@ -977,14 +761,8 @@ describe("scrolling", () => {
render(<SessionEditor input={input("A")} />);
expect(await screen.findByText("Answer A")).toBeInTheDocument();
const el = stream();
Object.defineProperty(el, "scrollHeight", {
configurable: true,
value: 1000,
});
Object.defineProperty(el, "clientHeight", {
configurable: true,
value: 400,
});
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
el.scrollTop = 600;
fireEvent.scroll(el);
el.scrollTop = 0;
@@ -997,22 +775,13 @@ describe("scrolling", () => {
render(<SessionEditor input={input("A")} />);
expect(await screen.findByText("Answer A")).toBeInTheDocument();
const el = stream();
Object.defineProperty(el, "scrollHeight", {
configurable: true,
value: 1000,
});
Object.defineProperty(el, "clientHeight", {
configurable: true,
value: 400,
});
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
fireEvent.wheel(el);
el.scrollTop = 100;
fireEvent.scroll(el);
const jump = await screen.findByRole("button", { name: "Jump to Latest" });
Object.defineProperty(el, "scrollTo", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(el, "scrollTo", { configurable: true, value: vi.fn() });
fireEvent.click(jump);
expect(el.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" });
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
@@ -1032,11 +801,7 @@ describe("tool calls", () => {
});
it("folds consecutive finished tool calls into one collapsed row", async () => {
const edit = (id: string) =>
tool(id, {
kind: "edit",
diffs: [{ path: "/project/a.ts", added: 2, removed: 1 }],
});
const edit = (id: string) => tool(id, { kind: "edit", diffs: [{ path: "/project/a.ts", added: 2, removed: 1 }] });
backend.entries.set("A", [edit("one"), edit("two"), { type: "agent", text: "Answer A" }]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
@@ -1057,11 +822,7 @@ describe("a pending question", () => {
id: 7,
title: question,
options: [
{
id: "a",
name: "Ship it as is and record it (Recommended)",
kind: "allow_once",
},
{ id: "a", name: "Ship it as is and record it (Recommended)", kind: "allow_once" },
{ id: "b", name: "Detect the request in code", kind: "allow_once" },
{ id: "c", name: "Skip", kind: "reject_once" },
],
@@ -1072,9 +833,7 @@ describe("a pending question", () => {
backend.answers = [];
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const card = await screen.findByRole("region", {
name: "Allow tool call?",
});
const card = await screen.findByRole("region", { name: "Allow tool call?" });
expect(within(card).getByText(question.replaceAll("\n", " "))).toBeInTheDocument();
expect(screen.getAllByText(question.replaceAll("\n", " "))).toHaveLength(1);
const answers = within(card).getAllByRole("button");
@@ -1102,13 +861,7 @@ describe("a pending AskUserQuestion", () => {
header: answerKey,
question: "Which?",
multiSelect: false,
options: [
{
label: index === 0 ? "One" : "Two",
description: "",
preview: null,
},
],
options: [{ label: index === 0 ? "One" : "Two", description: "", preview: null }],
})),
},
]);
@@ -1138,16 +891,8 @@ describe("a pending AskUserQuestion", () => {
question: "Which layout?",
multiSelect: false,
options: [
{
label: "Table (Recommended)",
description: "One row per check",
preview: null,
},
{
label: "List",
description: "Grouped under headings",
preview: null,
},
{ label: "Table (Recommended)", description: "One row per check", preview: null },
{ label: "List", description: "Grouped under headings", preview: null },
],
},
{
@@ -1199,20 +944,11 @@ describe("a pending AskUserQuestion", () => {
fireEvent.click(within(card).getByRole("checkbox", { name: /^Disk/ }));
fireEvent.click(within(card).getByRole("checkbox", { name: /^Other/ }));
expect(submit).toHaveProperty("disabled", true);
fireEvent.change(within(card).getByPlaceholderText("Type your answer"), {
target: { value: "GPU temps" },
});
fireEvent.change(within(card).getByPlaceholderText("Type your answer"), { target: { value: "GPU temps" } });
fireEvent.click(submit);
await waitFor(() =>
expect(backend.answers).toEqual([
[
"A",
9,
JSON.stringify({
"Which layout?": ["List"],
"Which checks?": ["Disk", "GPU temps"],
}),
],
["A", 9, JSON.stringify({ "Which layout?": ["List"], "Which checks?": ["Disk", "GPU temps"] })],
]),
);
});
@@ -1272,11 +1008,7 @@ describe("a pending permission request above the composer", () => {
const command = 'python3 -c \'from pathlib import Path\np = Path("note.txt")\np.write_text("one\\ntwo")\'';
const options = [
{ id: "opaque-once", name: "Allow Once", kind: "allow_once" },
{
id: "opaque-policy",
name: `Always Allow \`${command}\``,
kind: "allow_always",
},
{ id: "opaque-policy", name: `Always Allow \`${command}\``, kind: "allow_always" },
{ id: "opaque-no", name: "Decline", kind: "reject_once" },
{ id: "opaque-stop", name: "Decline and Stop", kind: "reject_always" },
];
@@ -1293,20 +1025,11 @@ describe("a pending permission request above the composer", () => {
diffs: [],
locations: [],
},
{
type: "permission",
id: 12,
title: `Run ${command}`,
command,
options,
chosen: null,
},
{ type: "permission", id: 12, title: `Run ${command}`, command, options, chosen: null },
]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const card = await screen.findByRole("region", {
name: "codex wants to run a command",
});
const card = await screen.findByRole("region", { name: "codex wants to run a command" });
expect(screen.getAllByRole("region", { name: "codex wants to run a command" })).toHaveLength(1);
expect(screen.queryByRole("alert")).toBeNull();
expect(stream()).not.toContainElement(card);
@@ -1325,58 +1048,36 @@ describe("a pending permission request above the composer", () => {
});
it.each(options)("sends the original $kind option id, then records it in the conversation", async (option) => {
const pending: AgentEntry = {
type: "permission",
id: 12,
title: `Run ${command}`,
command,
options,
chosen: null,
};
const pending: AgentEntry = { type: "permission", id: 12, title: `Run ${command}`, command, options, chosen: null };
backend.answers = [];
backend.entries.set("A", [pending]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
const card = await screen.findByRole("region", {
name: "codex wants to run a command",
});
const card = await screen.findByRole("region", { name: "codex wants to run a command" });
const button = within(card).getAllByRole("button")[options.indexOf(option)]!;
button.focus();
fireEvent.click(button);
await waitFor(() => expect(backend.answers).toEqual([["A", 12, option.id]]));
act(() =>
useAgents.setState({
entries: new Map(backend.entries).set("A", [{ ...pending, chosen: option.name }]),
}),
useAgents.setState({ entries: new Map(backend.entries).set("A", [{ ...pending, chosen: option.name }]) }),
);
expect(card).not.toBeInTheDocument();
const record = within(stream()).getByRole("region", {
name: "codex wants to run a command",
});
const record = within(stream()).getByRole("region", { name: "codex wants to run a command" });
expect(record.querySelector("pre")).toBeNull();
expect(within(record).queryByRole("button")).toBeNull();
expect(record.textContent).toContain("python3 …");
expect(record.textContent).not.toContain("write_text");
await waitFor(() => expect(screen.getByRole("textbox", { name: "Message" })).toHaveFocus());
await waitFor(() => expect(document.activeElement?.tagName).toBe("TEXTAREA"));
});
it("is answered with a number key when the focus isn't in a text field", async () => {
const pending: AgentEntry = {
type: "permission",
id: 12,
title: `Run ${command}`,
command,
options,
chosen: null,
};
const pending: AgentEntry = { type: "permission", id: 12, title: `Run ${command}`, command, options, chosen: null };
backend.answers = [];
backend.entries.set("A", [pending]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
await screen.findByRole("region", { name: "codex wants to run a command" });
fireEvent.keyDown(screen.getByRole("textbox", { name: "Message" }), {
key: "1",
});
fireEvent.keyDown(document.querySelector("textarea")!, { key: "1" });
expect(backend.answers).toEqual([]);
const elsewhere = document.body.appendChild(document.createElement("div"));
fireEvent.keyDown(elsewhere, { key: "1" });
@@ -1402,20 +1103,12 @@ describe("a pending permission request above the composer", () => {
},
]);
backend.entries.set("B", [
{
type: "permission",
id: 4,
title: "The agent asks for permission",
options: [],
chosen: null,
},
{ type: "permission", id: 4, title: "The agent asks for permission", options: [], chosen: null },
]);
backend.answers = [];
useAgents.setState({ entries: new Map(backend.entries) });
const { unmount } = render(<SessionEditor input={input("A")} />);
const line = await screen.findByRole("region", {
name: "codex wants to run a command",
});
const line = await screen.findByRole("region", { name: "codex wants to run a command" });
const answers = within(line.querySelector<HTMLElement>(`.${styles.permissionAnswers}`)!).getAllByRole("button");
expect(answers.map((b) => b.textContent)).toEqual(["Allow Once", "Decline"]);
fireEvent.click(answers[0]!);
@@ -1423,9 +1116,7 @@ describe("a pending permission request above the composer", () => {
unmount();
render(<SessionEditor input={input("B")} />);
const card = await screen.findByRole("region", {
name: "Allow tool call?",
});
const card = await screen.findByRole("region", { name: "Allow tool call?" });
fireEvent.click(within(card).getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(backend.answers).toEqual([
@@ -1486,9 +1177,7 @@ describe("conversation loading", () => {
});
it("keeps subagent failures separate and retries that subagent", async () => {
useAgents.setState({
entries: new Map([["A", backend.entries.get("A")!]]),
});
useAgents.setState({ entries: new Map([["A", backend.entries.get("A")!]]) });
backend.entryError = "No such subagent";
render(<SessionEditor input={input("A")} />);
fireEvent.click(screen.getByRole("button", { name: /Explore task A/ }));
@@ -1562,9 +1251,7 @@ describe("session state across tasks and hosts", () => {
it("opens a long conversation at its end and renders the earlier entries after it", async () => {
const entries = Array.from({ length: 150 }, (_, i): AgentEntry => ({ type: "agent", text: `Reply ${i}` }));
useAgents.setState({
entries: new Map(useAgents.getState().entries).set("A", entries),
});
useAgents.setState({ entries: new Map(useAgents.getState().entries).set("A", entries) });
render(<SessionEditor input={input("A")} />);
expect(screen.getByText("Reply 149")).toBeInTheDocument();
expect(await screen.findByText("Reply 0")).toBeInTheDocument();
@@ -1613,7 +1300,7 @@ describe("session state across tasks and hosts", () => {
const markVisible = vi.spyOn(await import("./store"), "markVisible");
vi.spyOn(document, "hasFocus").mockReturnValue(true);
const message = screen.getByRole("textbox", { name: "Message" });
typeIn(message, "Draft to keep");
fireEvent.change(message, { target: { value: "Draft to keep" } });
const conversation = stream();
fireEvent.click(screen.getByRole("button", { name: /1 changed file/ }));
expect(screen.getByRole("complementary", { name: "Changes" })).toBeVisible();
@@ -1628,7 +1315,7 @@ describe("session state across tasks and hosts", () => {
expect(markVisible).toHaveBeenCalledWith("A");
expect(useAgents.getState().unread.has("A")).toBe(false);
expect(screen.getByRole("textbox", { name: "Message" })).toBe(message);
expect(textOf(message)).toBe("Draft to keep");
expect(message).toHaveValue("Draft to keep");
expect(useAgents.getState().composers.get("A")?.text).toBe("Draft to keep");
expect(JSON.parse(localStorage.getItem("openidea.agents.changesOpen.v1")!).A).toBe(true);
});
@@ -1654,10 +1341,7 @@ describe("session state across tasks and hosts", () => {
fireEvent.wheel(stream());
stream().scrollTop = stick ? height - viewport : 230;
fireEvent.scroll(stream());
expect(useAgents.getState().scrollPositions.get("A")).toEqual({
top: stick ? 1600 : 230,
stick,
});
expect(useAgents.getState().scrollPositions.get("A")).toEqual({ top: stick ? 1600 : 230, stick });
view.unmount();
viewport = 150;
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
@@ -1720,39 +1404,24 @@ describe("meters in the task header", () => {
it("shows the provider's usage limits by default and each meter as Settings › Tools › Agents › Chat allows", async () => {
const reported: AgentTask = {
...task("A"),
usage: {
used: 24_000,
size: 200_000,
cost: { amount: 0.42, currency: "USD" },
},
usage: { used: 24_000, size: 200_000, cost: { amount: 0.42, currency: "USD" } },
};
const limits = [
{ window: "five_hour", usedPercent: 19, resetsMs: null },
{ window: "seven_day", usedPercent: 9.4, resetsMs: null },
];
useAgents.setState({
tasks: new Map([["A", reported]]),
limits: new Map([["codex", limits]]),
});
useAgents.setState({ tasks: new Map([["A", reported]]), limits: new Map([["codex", limits]]) });
render(<SessionEditor input={input("A")} />);
await screen.findByText("Answer A");
const usage = screen.getByLabelText(/^Context /);
expect(usage).toHaveTextContent("24k/200k (12%)");
expect(usage.querySelector(`.${styles.usageBar} span`)).toHaveStyle({
width: "12%",
});
expect(usage.querySelector(`.${styles.usageBar} span`)).toHaveStyle({ width: "12%" });
expect(screen.getByText("5h 19%")).toBeInTheDocument();
expect(screen.getByText("7d 9%")).toBeInTheDocument();
expect(screen.queryByText("$0.42")).toBeNull();
act(() =>
useProject.setState({
settings: {
agents: {
showContextUsage: false,
showUsageLimits: false,
showCost: true,
},
},
settings: { agents: { showContextUsage: false, showUsageLimits: false, showCost: true } },
}),
);
expect(screen.queryByText("24k/200k (12%)")).toBeNull();
@@ -1771,20 +1440,8 @@ describe("sessions from other apps", () => {
it("lists external sessions in the history popup and imports one as a task", async () => {
backend.external = [
{
provider: "claude",
sessionId: "abc",
title: "Login fix",
cwd: "/work/app",
updatedMs: Date.now(),
},
{
provider: "codex",
sessionId: "thread-1",
title: "Add tests",
cwd: null,
updatedMs: 0,
},
{ provider: "claude", sessionId: "abc", title: "Login fix", cwd: "/work/app", updatedMs: Date.now() },
{ provider: "codex", sessionId: "thread-1", title: "Add tests", cwd: null, updatedMs: 0 },
];
await openHistory();
expect(screen.getByText("Login fix")).toBeInTheDocument();
@@ -1827,17 +1484,7 @@ describe("changes refresh", () => {
vi.useFakeTimers();
useChangesOptions.setState({ groupByCommit: true });
useAgents.setState({
tasks: new Map([
[
"A",
{
...task("A"),
isolation: "worktree",
cwd: "/worktrees/a",
projectRoot: "/project",
},
],
]),
tasks: new Map([["A", { ...task("A"), isolation: "worktree", cwd: "/worktrees/a", projectRoot: "/project" }]]),
});
render(<SessionEditor input={input("A")} />);
await act(async () => {});
@@ -1931,17 +1578,12 @@ describe("grouped changes", () => {
});
const worktreeTask = () => {
useChangesOptions.setState({ groupByCommit: true });
useAgents.setState({
tasks: new Map([["A", { ...task("A"), isolation: "worktree" }]]),
});
useAgents.setState({ tasks: new Map([["A", { ...task("A"), isolation: "worktree" }]]) });
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
};
it("puts committed files under their commit and rolls back only the uncommitted ones", async () => {
backend.grouped = {
uncommitted: [change("a.ts")],
commits: [commit({ files: [change("b.ts")] })],
};
backend.grouped = { uncommitted: [change("a.ts")], commits: [commit({ files: [change("b.ts")] })] };
worktreeTask();
render(<SessionEditor input={input("A")} />);
@@ -1978,15 +1620,10 @@ describe("grouped changes", () => {
});
it("accepts the task's committed work too, not only the uncommitted files", async () => {
backend.grouped = {
uncommitted: [],
commits: [commit({ files: [change("b.ts")] })],
};
backend.grouped = { uncommitted: [], commits: [commit({ files: [change("b.ts")] })] };
backend.changes = [change("b.ts")];
worktreeTask();
useAgents.setState({
tasks: new Map([["A", { ...task("A"), isolation: "worktree", status: "ready" }]]),
});
useAgents.setState({ tasks: new Map([["A", { ...task("A"), isolation: "worktree", status: "ready" }]]) });
render(<SessionEditor input={input("A")} />);
await screen.findByText("Committed");
@@ -0,0 +1,69 @@
import { useLayoutEffect, useState, type RefObject } from "react";
import type { VirtualElement } from "@floating-ui/react-dom";
import styles from "./Composer.module.css";
const TEXT_STYLES = [
"fontFamily",
"fontSize",
"fontWeight",
"fontStyle",
"fontVariant",
"lineHeight",
"letterSpacing",
"wordSpacing",
"textIndent",
"textAlign",
"textTransform",
"tabSize",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
] as const;
/** A textarea has no text DOM to measure; mirror its wrapping and scroll offset. */
function caretRect(textarea: HTMLTextAreaElement, caret: number): DOMRect {
const computed = getComputedStyle(textarea);
const mirror = document.createElement("div");
mirror.className = styles.caretMirror ?? "";
for (const property of TEXT_STYLES) mirror.style[property] = computed[property];
mirror.style.width = `${textarea.clientWidth}px`;
mirror.textContent = textarea.value.slice(0, caret);
const marker = document.createElement("span");
marker.textContent = textarea.value.slice(caret) || "\u200b";
mirror.append(marker);
document.body.append(mirror);
try {
const box = textarea.getBoundingClientRect();
const lineHeight = parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.5;
return new DOMRect(
box.left + textarea.clientLeft + marker.offsetLeft - textarea.scrollLeft,
box.top + textarea.clientTop + marker.offsetTop - textarea.scrollTop,
0,
lineHeight,
);
} finally {
mirror.remove();
}
}
export function useCompletionAnchor(
ref: RefObject<HTMLTextAreaElement | null>,
caret: number | null,
text: string,
): VirtualElement | null {
const [anchor, setAnchor] = useState<VirtualElement | null>(null);
useLayoutEffect(() => {
const textarea = ref.current;
if (!textarea || caret === null) {
setAnchor(null);
return;
}
const update = () =>
setAnchor({ contextElement: textarea, getBoundingClientRect: () => caretRect(textarea, caret) });
update();
textarea.addEventListener("scroll", update);
return () => textarea.removeEventListener("scroll", update);
}, [ref, caret, text]);
return anchor;
}
+2 -19
View File
@@ -5,15 +5,9 @@
*/
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import {
registerAgentContextSource,
registerMenuGroup,
registerOverlay,
registerStatusItem,
registerToolWindow,
} from "@/workbench/contributions";
import { registerMenuGroup, registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions";
import { getContext } from "@/platform/context";
import { clearProblems, installProblemsEvents, problemsReport, useProblems } from "./problemsStore";
import { clearProblems, installProblemsEvents, useProblems } from "./problemsStore";
import { installProblemDecorations } from "./decorations";
import { ProblemsHeaderActions, ProblemsTabs, ProblemsView, useProblemsHeaderMenu } from "./ProblemsView";
import { UsagesTabs, UsagesView, useUsagesHeaderMenu } from "./UsagesView";
@@ -51,17 +45,6 @@ export function activate(): () => void {
const disposers = [
unsubProject,
installProblemsEvents(),
registerAgentContextSource({
id: "problems",
title: "Problems",
description: "The project's errors and warnings",
icon: "problems",
resolve: async () => {
const root = useProject.getState().project?.root;
const text = root ? problemsReport(useProblems.getState().map, root) : null;
return text ? { kind: "block", info: "problems", text } : null;
},
}),
reportProblems(),
installProblemDecorations(),
installServerEvents(),
@@ -8,7 +8,6 @@ import {
fileDiagnostics,
filePaths,
mergeDiagnostics,
problemsReport,
profiledProblems,
projectCounts,
useProblems,
@@ -135,19 +134,3 @@ describe("filePaths", () => {
expect(filePaths(map, "/p/none.py")).toEqual([]);
});
});
describe("problemsReport", () => {
it("lists errors before warnings with their places, leaving out hints, up to a limit", () => {
let map = mergeDiagnostics(new Map(), "/p/src/a.ts", "tsc", [d(3, "warning", "ts"), d(0, "hint")]);
map = mergeDiagnostics(map, "/p/src/b.ts", "tsc", [d(9, "error"), d(1, "error")]);
expect(problemsReport(map, "/p")).toBe(
[
"src/b.ts:2:1: error: line 1 (tsc)",
"src/b.ts:10:1: error: line 9 (tsc)",
"src/a.ts:4:1: warning: line 3 (ts)",
].join("\n"),
);
expect(problemsReport(map, "/p", 1)).toBe("src/b.ts:2:1: error: line 1 (tsc)\n… 2 more");
expect(problemsReport(mergeDiagnostics(new Map(), "/p/a", "x", [d(0, "info")]), "/p")).toBeNull();
});
});
@@ -188,25 +188,3 @@ export function installProblemsEvents(): () => void {
offProfile();
};
}
/**
* Errors and warnings as `rel:line:col: severity: message (source)` lines, errors first, at most
* `max`; null when there are none. For an agent's context.
*/
export function problemsReport(map: ProblemsMap, root: string, max = 60): string | null {
const rows: { severity: Severity; text: string }[] = [];
for (const path of map.keys()) {
const rel = path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
for (const d of fileDiagnostics(map, path)) {
if (d.severity !== "error" && d.severity !== "warning") continue;
const at = `${rel}:${d.range.start.line + 1}:${d.range.start.col + 1}`;
const source = d.source ? ` (${d.source})` : "";
rows.push({ severity: d.severity, text: `${at}: ${d.severity}: ${d.message.split("\n")[0]}${source}` });
}
}
if (!rows.length) return null;
rows.sort((a, b) => Number(a.severity !== "error") - Number(b.severity !== "error"));
const shown = rows.slice(0, max).map((r) => r.text);
if (rows.length > max) shown.push(`… ${rows.length - max} more`);
return shown.join("\n");
}
+1 -6
View File
@@ -5,7 +5,6 @@
* a terminal feeds its process).
*/
import { writeClipboardText } from "@/platform/clipboard";
import { noteCopySource } from "@/platform/clipboardSource";
import { installTerminalDrop } from "./drop";
import { installDomRowCache } from "./domRowCache";
import { installTerminalPaste } from "./paste";
@@ -65,11 +64,7 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal
if (action.kind === "send" && e.key === "Enter") {
if (e.type === "keydown") term.input(action.data);
} else if (action.kind === "copy") {
if (e.type === "keydown") {
const text = term.getSelection();
noteCopySource(text, { kind: "terminal", title: "Terminal" });
void writeClipboardText(text).catch(() => undefined);
}
if (e.type === "keydown") void writeClipboardText(term.getSelection()).catch(() => undefined);
} else {
return true;
}
-11
View File
@@ -9,7 +9,6 @@ import { useProject } from "@/platform/project";
import { onWorkflowSettingsChange } from "@/platform/workflowSettings";
import {
provideEmbeddedTerminal,
registerAgentContextSource,
registerEditorKind,
registerToolWindow,
toolWindowService,
@@ -103,16 +102,6 @@ export function activate(): () => void {
},
])
: () => {},
registerAgentContextSource({
id: "terminal",
title: "Terminal",
description: "The active terminal's recent output",
icon: "terminal",
resolve: async () => {
const output = terminalRuntime()?.activeSession()?.recentOutput();
return output?.text ? { kind: "block", info: `terminal ${output.title}`.trim(), text: output.text } : null;
},
}),
registerToolWindow({
id: WINDOW,
title: "Terminal",
-19
View File
@@ -4,7 +4,6 @@
* session survives its tool window being hidden or its pane being re-mounted.
*/
import { writeClipboardText } from "@/platform/clipboard";
import { noteCopySource } from "@/platform/clipboardSource";
import { Terminal, type IDisposable, type ILink, type ILinkHandler } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { SearchAddon, type ISearchOptions } from "@xterm/addon-search";
@@ -779,27 +778,9 @@ export class TerminalSession {
this.term.clearSelection();
}
/** The last `max` lines down to the cursor (wrapped rows joined), for an agent's context. */
recentOutput(max = 80): { title: string; text: string } {
const buffer = this.term.buffer.active;
const end = buffer.baseY + buffer.cursorY;
const lines: string[] = [];
for (let y = Math.max(0, end - max + 1); y <= end; y++) {
const line = buffer.getLine(y);
if (!line) continue;
const text = line.translateToString(true);
if (line.isWrapped && lines.length) lines[lines.length - 1] += text;
else lines.push(text);
}
while (lines.length && !lines.at(-1)!.trim()) lines.pop();
while (lines.length && !lines[0]!.trim()) lines.shift();
return { title: this.snapshot.appTitle ?? this.snapshot.title, text: lines.join("\n") };
}
copySelection(): boolean {
const text = this.term.getSelection();
if (!text) return false;
noteCopySource(text, { kind: "terminal", title: this.snapshot.appTitle ?? this.snapshot.title });
void writeClipboardText(text).catch(() => undefined);
return true;
}
-19
View File
@@ -1,19 +0,0 @@
/**
* Where the text last copied in the IDE came from (an editor's lines, a terminal), so a paste
* of the same text can say so: the agent composer turns it into a reference or a terminal block.
*/
export type CopySource = { kind: "file"; path: string; from: number; to: number } | { kind: "terminal"; title: string };
const normalize = (text: string) => text.replace(/\r\n?/g, "\n").replace(/\s+$/, "");
let last: { text: string; source: CopySource } | null = null;
export function noteCopySource(text: string, source: CopySource) {
const normalized = normalize(text);
last = normalized ? { text: normalized, source } : null;
}
/** The source of `text` when it is what was last copied in the IDE. */
export function copySourceOf(text: string): CopySource | null {
return last && last.text === normalize(text) ? last.source : null;
}
-24
View File
@@ -1507,30 +1507,6 @@
"Archived agent tabs to keep": "Archived agent tabs to keep",
"Beyond this many archived agent tabs, the oldest are closed.": "Beyond this many archived agent tabs, the oldest are closed.",
"Archived ({count})": "Archived ({count})",
"Insert": "Insert",
"Drop to reference": "Drop to reference",
"Reference {rel}": "Reference {rel}",
"Folder reference {name}": "Folder reference {name}",
"File reference {name}": "File reference {name}",
"File reference {name}, lines {lines}": "File reference {name}, lines {lines}",
"Lines {lines}": "Lines {lines}",
"Open Editors": "Open Editors",
"Enter Folder": "Enter Folder",
"Click to edit": "Click to edit",
"Show less": "Show less",
"Show {more} more lines": "Show {more} more lines",
"Terminal": "Terminal",
"Code": "Code",
"Copy": "Copy",
"Nothing to add right now": "Nothing to add right now",
"Pasted Text": "Pasted Text",
"Files with uncommitted changes": "Files with uncommitted changes",
"The project's errors and warnings": "The project's errors and warnings",
"The active terminal's recent output": "The active terminal's recent output",
"Stack Trace": "Stack Trace",
"Context": "Context",
"Changes": "Changes",
"Problems": "Problems",
"no usages": "no usages",
"1 usage": "1 usage",
"{n} usages": "{n} usages",
-19
View File
@@ -7904,25 +7904,6 @@
"Archived agent tabs to keep": "保留的已归档智能体标签数",
"Beyond this many archived agent tabs, the oldest are closed.": "已归档的智能体标签超过这个数量时,最旧的会被关闭。",
"Archived ({count})": "归档({count})",
"Insert": "插入",
"Drop to reference": "松开以引用",
"Reference {rel}": "引用 {rel}",
"Folder reference {name}": "文件夹引用 {name}",
"File reference {name}": "文件引用 {name}",
"File reference {name}, lines {lines}": "文件引用 {name},第 {lines} 行",
"Lines {lines}": "第 {lines} 行",
"Open Editors": "已打开的编辑器",
"Enter Folder": "进入文件夹",
"Click to edit": "点击编辑",
"Show less": "收起",
"Show {more} more lines": "展开其余 {more} 行",
"Nothing to add right now": "暂时没有可添加的内容",
"Pasted Text": "粘贴的文本",
"Files with uncommitted changes": "有未提交改动的文件",
"The project's errors and warnings": "项目中的错误和警告",
"The active terminal's recent output": "当前终端的最近输出",
"Stack Trace": "堆栈跟踪",
"Context": "上下文",
"no usages": "无用法",
"1 usage": "1 个用法",
"{n} usages": "{n} 个用法",
+6 -7
View File
@@ -1,12 +1,11 @@
/* The chip surface: shared with the pickers that open (Select's chip variant) and the changes
button. Flat like the main toolbar's project and branch widgets: no frame at rest, a toolbar
button's hover on the ones that open. */
/* The chip surface: shared with the pickers that open (Select's chip variant) and the changes button. */
.surface {
box-sizing: border-box;
height: 22px;
border-radius: var(--radius-toolbar-button);
background: transparent;
color: var(--fg-secondary);
border-radius: 11px;
background: var(--bg-elev);
box-shadow: inset 0 0 0 1px var(--border);
color: var(--fg-strong);
font-size: var(--fs-small);
}
/* A read-only fact: same shape and height as the pickers it sits beside. */
@@ -17,7 +16,7 @@
gap: 4px;
max-width: 100%;
min-width: 0;
padding: 0 6px;
padding: 0 8px;
line-height: 1;
white-space: nowrap;
}
+16
View File
@@ -0,0 +1,16 @@
/* Equal negative margins preserve native textarea wrapping and caret positions. */
.tag {
padding: 1px 3px;
margin: 0 -3px;
border-radius: var(--radius-selection);
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
color: var(--fg-strong);
background: color-mix(in srgb, var(--fg) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 20%, transparent);
}
.accent {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 20%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
}
+8
View File
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
import clsx from "clsx";
import styles from "./InlineTag.module.css";
/** A text-sized tag that preserves glyph positions in mirrored inputs. */
export function InlineTag({ children, accent = false }: { children: ReactNode; accent?: boolean }) {
return <span className={clsx(styles.tag, accent && styles.accent)}>{children}</span>;
}
+2 -3
View File
@@ -41,12 +41,11 @@
/* A picker that reads as a chip beside the facts it belongs with (Chip). */
.chip {
composes: surface from "./Chip.module.css";
padding: 0 4px 0 6px;
padding: 0 4px 0 8px;
}
.chip:hover,
.chip[aria-expanded="true"] {
background: var(--bg-toolbar-hover);
color: var(--fg-strong);
background: var(--bg-hover);
}
.chip:focus-visible {
outline: none;
+1
View File
@@ -43,4 +43,5 @@ export { lockDragCursor, type DragCursor } from "./dragCursor";
export { useSanitizedField } from "./useSanitizedField";
export * from "./rowSelection";
export * from "./LogPane";
export * from "./InlineTag";
export * from "./Chip";
-29
View File
@@ -812,35 +812,6 @@ let commitServiceImpl: CommitService | undefined;
export const provideCommitService = (s: CommitService | undefined) => void (commitServiceImpl = s);
export const maybeCommitService = (): CommitService | undefined => commitServiceImpl;
/**
* Context a feature offers the agent composer's `@` menu (`@problems`, `@terminal`): what
* choosing it inserts, a ``` block (`info` names its kind: `problems`, `terminal <title>`)
* or references to files.
*/
export type AgentContext = { kind: "block"; info: string; text: string } | { kind: "files"; paths: string[] };
export interface AgentContextSource {
/** Typed after `@`. */
id: string;
/** English: shown through the text catalog. */
title: string;
description: string;
icon: string;
/** What to insert now; null when there is nothing (no problems, no terminal). */
resolve(): Promise<AgentContext | null>;
}
const agentContextSources = new Map<string, AgentContextSource>();
export function registerAgentContextSource(source: AgentContextSource): () => void {
agentContextSources.set(source.id, source);
return () => {
if (agentContextSources.get(source.id) === source) agentContextSources.delete(source.id);
};
}
export const getAgentContextSources = (): AgentContextSource[] => [...agentContextSources.values()];
/** A before-commit check contributed by an extension (a plugin's `commitChecks`). */
export interface ContributedCommitCheck {
/** Stable key (`<plugin id>/<check id>`), stored when the check is turned off. */