mirror of
https://github.com/cline/cline.git
synced 2026-10-25 10:26:36 +08:00
feat(desktop): token chips for slash commands and file mentions in the composer (#15070)
* feat(ui): support an inline highlight range in AgentComposerTextarea * feat(desktop): highlight a leading slash command in the composer * feat(ui): multiple composer tokens, bolder chip, whole-token delete * feat(desktop): highlight known @file mentions in the composer * fix(ui): keep composer token chips within the line box so the top border isn't clipped * fix(desktop): only open the slash menu at the start of the prompt * feat(ui): export the composer token chip class * refactor(desktop): share known composer tokens outside the composer * feat(desktop): record attached files on projected user messages * feat(desktop): chip slash commands and file mentions in sent messages * fix(desktop): key chip token processors by their tokens * test(desktop): fix attached files test typecheck * fix(ui): reserve the scrollbar gutter so composer chips stay aligned * test(desktop): cover loading slash commands before the menu opens * fix(desktop): only treat the slash command catalog as loaded once it arrives * fix(desktop): mark the command catalog loaded only after both catalogs arrive * fix(desktop): strip mention punctuation without a backtracking regex and normalize mention paths * fix(desktop): match sent mentions to attached files exactly, relative to the session workspace * fix(desktop): resolve SSH sessions' attached files against the remote workspace --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor Agent
parent
d35ad45e49
commit
1e39ae2f2d
@@ -32,7 +32,7 @@
|
||||
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/messages webview/components/ui/markdown.test.tsx webview/components/ui/markdown.interactions.test.tsx --config vitest.config.ts",
|
||||
"pretest:settings-ui": "bun run build:ui",
|
||||
"test:settings-ui": "vitest run webview/components/views/settings webview/components/ui/switch-integration.test.tsx webview/components/quit-hold-handler.test.tsx webview/lib/keybindings.test.ts --config vitest.config.ts",
|
||||
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/hub-startup-screen.test.tsx webview/hooks/use-hub-startup.test.tsx webview/hooks/use-remote-environment-bootstrap.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
|
||||
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/composer-tokens.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/hub-startup-screen.test.tsx webview/hooks/use-hub-startup.test.tsx webview/hooks/use-remote-environment-bootstrap.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
|
||||
"test:sidecar-startup": "bun test scripts/desktop-startup.test.ts",
|
||||
"test:desktop-client": "vitest run webview/lib/desktop-client.test.ts --config vitest.config.ts",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
|
||||
@@ -2138,15 +2138,20 @@ export async function handleCommand(
|
||||
return await readSessionMessages(ctx, sessionId, maxMessages, messages);
|
||||
}
|
||||
const binding = await getCommandSessionBinding(ctx, sessionId, args);
|
||||
const remoteMessages =
|
||||
binding?.kind === "ssh"
|
||||
? await binding.sessionManager.readMessages(sessionId)
|
||||
: undefined;
|
||||
if (binding?.kind !== "ssh") {
|
||||
return await readSessionMessages(ctx, sessionId, maxMessages);
|
||||
}
|
||||
const [remoteMessages, remoteSession] = await Promise.all([
|
||||
binding.sessionManager.readMessages(sessionId),
|
||||
// Only refines mention chips, so a failed lookup must not fail the read.
|
||||
binding.sessionManager.get(sessionId).catch(() => undefined),
|
||||
]);
|
||||
return await readSessionMessages(
|
||||
ctx,
|
||||
sessionId,
|
||||
maxMessages,
|
||||
remoteMessages,
|
||||
remoteSession?.workspaceRoot || remoteSession?.cwd || undefined,
|
||||
);
|
||||
}
|
||||
if (command === "read_session_hooks") {
|
||||
|
||||
@@ -901,8 +901,10 @@ describe("remote environment command routing", () => {
|
||||
]);
|
||||
const update = vi.fn(async () => ({ updated: true }));
|
||||
const deleteSession = vi.fn(async () => true);
|
||||
const getSession = vi.fn(async () => ({ workspaceRoot: "/remote/repo" }));
|
||||
const sessionManager = {
|
||||
readMessages,
|
||||
get: getSession,
|
||||
update,
|
||||
delete: deleteSession,
|
||||
dispose: vi.fn(async () => undefined),
|
||||
@@ -920,6 +922,7 @@ describe("remote environment command routing", () => {
|
||||
}),
|
||||
).resolves.toHaveLength(1);
|
||||
expect(readMessages).toHaveBeenCalledWith("remote-session");
|
||||
expect(getSession).toHaveBeenCalledWith("remote-session");
|
||||
|
||||
ctx.liveSessions.set("same-id", {
|
||||
environmentId: "local",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { writeSessionManifest } from "../paths";
|
||||
import { readSessionMessages } from "./messages";
|
||||
|
||||
describe("readSessionMessages", () => {
|
||||
@@ -318,6 +322,94 @@ describe("readSessionMessages", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("records file blocks of a user message relative to the mention base", async () => {
|
||||
const sessionId = `file-projection-${Date.now()}`;
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "session-data-"));
|
||||
const previousDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
process.env.CLINE_SESSION_DATA_DIR = dataDir;
|
||||
try {
|
||||
writeSessionManifest(sessionId, {
|
||||
session_id: sessionId,
|
||||
workspace_root: "/repo",
|
||||
cwd: "/repo/.worktrees/feature",
|
||||
});
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
id: "user-files",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Review @src/app.ts" },
|
||||
{
|
||||
type: "file",
|
||||
path: "/repo/src/app.ts",
|
||||
content: "export {}",
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
path: "/elsewhere/notes.md",
|
||||
content: "notes",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
content: "Review @src/app.ts",
|
||||
meta: expect.objectContaining({
|
||||
attachedFiles: ["src/app.ts", "/elsewhere/notes.md"],
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (previousDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousDataDir;
|
||||
}
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves remote attached files against the remote mention base", async () => {
|
||||
const [message] = await readSessionMessages(
|
||||
{ liveSessions: new Map() } as Parameters<typeof readSessionMessages>[0],
|
||||
"remote-file-projection",
|
||||
800,
|
||||
[
|
||||
{
|
||||
id: "remote-user",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Review @src/app.ts" },
|
||||
{
|
||||
type: "file",
|
||||
path: "/home/dev/repo/src/app.ts",
|
||||
content: "export {}",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"/home/dev/repo",
|
||||
);
|
||||
expect(message).toMatchObject({
|
||||
content: "Review @src/app.ts",
|
||||
meta: { attachedFiles: ["src/app.ts"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves absolute user run counts when older messages are omitted", async () => {
|
||||
const sessionId = `run-count-projection-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import nodePath, { dirname } from "node:path";
|
||||
import {
|
||||
getUserRunSpan,
|
||||
projectSessionMessagesForDisplay,
|
||||
@@ -23,6 +23,31 @@ import {
|
||||
stringifyMessageContent,
|
||||
} from "./common";
|
||||
|
||||
/** The directory the runtime resolved this session's `@` mentions against. */
|
||||
function readMentionBaseDir(sessionId: string): string | undefined {
|
||||
const manifest = readSessionManifest(sessionId);
|
||||
const base = manifest?.workspace_root ?? manifest?.cwd;
|
||||
return typeof base === "string" && base.trim() ? base : undefined;
|
||||
}
|
||||
|
||||
// Relative to the mention base so the webview can match `@path` text exactly;
|
||||
// paths outside it stay absolute and simply never match a relative mention.
|
||||
function toMentionRelativePath(
|
||||
filePath: string,
|
||||
mentionBaseDir: string | undefined,
|
||||
): string {
|
||||
if (!mentionBaseDir) return filePath;
|
||||
// SSH sessions report paths from the remote host, whose separators can
|
||||
// differ from this machine's.
|
||||
const pathApi = mentionBaseDir.startsWith("/") ? nodePath.posix : nodePath;
|
||||
const relativePath = pathApi.relative(mentionBaseDir, filePath);
|
||||
return !relativePath ||
|
||||
relativePath.startsWith("..") ||
|
||||
pathApi.isAbsolute(relativePath)
|
||||
? filePath
|
||||
: relativePath.split(pathApi.sep).join("/");
|
||||
}
|
||||
|
||||
type ChatTurnResult = {
|
||||
usage?: {
|
||||
inputTokens?: number;
|
||||
@@ -338,6 +363,8 @@ export function readSessionMessagesSync(
|
||||
maxMessages = 800,
|
||||
/** Explicit authoritative source for remote sessions; bypasses local disk. */
|
||||
sourceMessages?: unknown[],
|
||||
/** Where a remote session resolved `@` mentions (its workspace root). */
|
||||
remoteMentionBaseDir?: string,
|
||||
): unknown[] {
|
||||
const isRemoteRead = sourceMessages !== undefined;
|
||||
const persisted = isRemoteRead
|
||||
@@ -369,6 +396,9 @@ export function readSessionMessagesSync(
|
||||
? new Map<number, StoredCheckpointEntry>()
|
||||
: readCheckpointEntriesByRunCount(sessionId);
|
||||
const pendingToolMessages = new Map<string, [number, string, unknown]>();
|
||||
const mentionBaseDir = isRemoteRead
|
||||
? remoteMentionBaseDir
|
||||
: readMentionBaseDir(sessionId);
|
||||
let userRunCount = 0;
|
||||
for (let idx = 0; idx < start; idx += 1) {
|
||||
const rawMessage = messages[idx];
|
||||
@@ -475,6 +505,7 @@ export function readSessionMessagesSync(
|
||||
|
||||
const textParts: string[] = [];
|
||||
const images: Array<{ id: string; mediaType: string; data: string }> = [];
|
||||
const attachedFiles: string[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
let reasoningRedacted = false;
|
||||
let textSegmentIndex = 0;
|
||||
@@ -656,6 +687,14 @@ export function readSessionMessagesSync(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (blockType === "file") {
|
||||
if (role === "user" && typeof record.path === "string") {
|
||||
attachedFiles.push(
|
||||
toMentionRelativePath(record.path, mentionBaseDir),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (blockType === "media" && isGeneratedMedia(record.media)) {
|
||||
flushTextParts();
|
||||
out.push({
|
||||
@@ -696,6 +735,21 @@ export function readSessionMessagesSync(
|
||||
textMeta = undefined;
|
||||
}
|
||||
}
|
||||
if (attachedFiles.length > 0) {
|
||||
// The webview chips `@` mentions in the user's text that resolved to
|
||||
// one of these attachments.
|
||||
const target = out
|
||||
.slice(outStartIndex)
|
||||
.find((item) => item.role === role && item.content);
|
||||
if (target) {
|
||||
target.meta = {
|
||||
...(target.meta && typeof target.meta === "object"
|
||||
? (target.meta as JsonRecord)
|
||||
: {}),
|
||||
attachedFiles,
|
||||
};
|
||||
}
|
||||
}
|
||||
flushReasoningParts();
|
||||
if (textMeta && out[outStartIndex]) {
|
||||
out[outStartIndex].meta = {
|
||||
@@ -735,6 +789,13 @@ export async function readSessionMessages(
|
||||
sessionId: string,
|
||||
maxMessages = 800,
|
||||
sourceMessages?: unknown[],
|
||||
remoteMentionBaseDir?: string,
|
||||
): Promise<unknown[]> {
|
||||
return readSessionMessagesSync(ctx, sessionId, maxMessages, sourceMessages);
|
||||
return readSessionMessagesSync(
|
||||
ctx,
|
||||
sessionId,
|
||||
maxMessages,
|
||||
sourceMessages,
|
||||
remoteMentionBaseDir,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { stripMentionTrailingPunctuation } from "@/lib/composer-tokens";
|
||||
|
||||
export const CHIP_TOKEN_TAG = "cline-chip-token";
|
||||
|
||||
type MarkdownNode = {
|
||||
type: string;
|
||||
value?: string;
|
||||
children?: MarkdownNode[];
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const SKIPPED_NODE_TYPES = new Set([
|
||||
"code",
|
||||
"definition",
|
||||
"html",
|
||||
"inlineCode",
|
||||
"link",
|
||||
"linkReference",
|
||||
]);
|
||||
|
||||
function chip(value: string): MarkdownNode {
|
||||
return {
|
||||
type: "chipToken",
|
||||
data: { hName: CHIP_TOKEN_TAG },
|
||||
children: [{ type: "text", value }],
|
||||
};
|
||||
}
|
||||
|
||||
function splitText(
|
||||
value: string,
|
||||
tokens: ReadonlySet<string>,
|
||||
allowCommand: boolean,
|
||||
): MarkdownNode[] {
|
||||
const nodes: MarkdownNode[] = [];
|
||||
let consumed = 0;
|
||||
for (const match of value.matchAll(/(^|\s)([/@]\S+)/g)) {
|
||||
const start = match.index + match[1].length;
|
||||
const raw = match[2];
|
||||
const isCommand = raw.startsWith("/");
|
||||
if (isCommand && !(allowCommand && start === 0)) continue;
|
||||
const token = isCommand ? raw : stripMentionTrailingPunctuation(raw);
|
||||
if (!tokens.has(token)) continue;
|
||||
if (start > consumed) {
|
||||
nodes.push({ type: "text", value: value.slice(consumed, start) });
|
||||
}
|
||||
nodes.push(chip(token));
|
||||
consumed = start + token.length;
|
||||
}
|
||||
if (consumed === 0) return [{ type: "text", value }];
|
||||
if (consumed < value.length) {
|
||||
nodes.push({ type: "text", value: value.slice(consumed) });
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the given exact tokens (a leading `/command`, `@path` mentions) in
|
||||
* {@link CHIP_TOKEN_TAG} elements, leaving code and links untouched.
|
||||
*
|
||||
* Use as `[remarkChipTokens, { tokens }]`: Streamdown caches processors by
|
||||
* plugin name and options, so the tokens must travel as options.
|
||||
*/
|
||||
export function remarkChipTokens(options: { tokens: string[] }) {
|
||||
const tokens = new Set(options.tokens);
|
||||
return (tree: MarkdownNode) => {
|
||||
let atDocumentStart = true;
|
||||
const walk = (node: MarkdownNode) => {
|
||||
if (!node.children) return;
|
||||
node.children = node.children.flatMap((child) => {
|
||||
if (child.type === "text") {
|
||||
const parts = splitText(child.value ?? "", tokens, atDocumentStart);
|
||||
atDocumentStart = false;
|
||||
return parts;
|
||||
}
|
||||
if (SKIPPED_NODE_TYPES.has(child.type) || !child.children) {
|
||||
atDocumentStart = false;
|
||||
return [child];
|
||||
}
|
||||
walk(child);
|
||||
return [child];
|
||||
});
|
||||
};
|
||||
walk(tree);
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { createDesktopMarkdownPlugins, MemoizedMarkdown } from "./markdown";
|
||||
|
||||
const chipTexts = (html: string) =>
|
||||
[...html.matchAll(/<span class="[^"]*inset-ring[^"]*">([^<]*)<\/span>/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
|
||||
describe("MemoizedMarkdown", () => {
|
||||
test("renders structured GFM content and blocks remote images", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
@@ -60,6 +65,35 @@ const ready = true;
|
||||
expect(html).not.toContain("flowchart LR");
|
||||
});
|
||||
|
||||
test("renders chip tokens in prose but not in code or mid-sentence commands", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown
|
||||
chipTokens={new Set(["/compact", "@src/app.ts"])}
|
||||
content={
|
||||
"/compact focus on @src/app.ts, then `@src/app.ts` and /compact"
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(chipTexts(html)).toEqual(["/compact", "@src/app.ts"]);
|
||||
expect(html).toContain("<code");
|
||||
expect(html).toContain("and /compact");
|
||||
});
|
||||
|
||||
test("chips each message with its own tokens", () => {
|
||||
const render = (tokens: string[], content: string) =>
|
||||
chipTexts(
|
||||
renderToStaticMarkup(
|
||||
<MemoizedMarkdown chipTokens={new Set(tokens)} content={content} />,
|
||||
),
|
||||
);
|
||||
|
||||
expect(render(["/team"], "/team plan it")).toEqual(["/team"]);
|
||||
expect(render(["@src/app.ts"], "look at @src/app.ts")).toEqual([
|
||||
"@src/app.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
test("names the diagram from the fence title", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MemoizedMarkdown
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentComposerTokenClassName } from "@cline/ui";
|
||||
import {
|
||||
agentMarkdownControls,
|
||||
DIAGRAM_LINK_HREF_ATTRIBUTE,
|
||||
@@ -12,9 +13,10 @@ import type {
|
||||
PointerEvent,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { isValidElement, memo, useRef, useState } from "react";
|
||||
import { isValidElement, memo, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
type Components,
|
||||
defaultRemarkPlugins,
|
||||
type ExtraProps,
|
||||
type LinkSafetyModalProps,
|
||||
Streamdown,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "./alert-dialog";
|
||||
import { CHIP_TOKEN_TAG, remarkChipTokens } from "./markdown-chip-tokens";
|
||||
|
||||
/**
|
||||
* Mermaid is an owned block, not Streamdown's built-in: Streamdown consults
|
||||
@@ -371,15 +374,26 @@ function useDiagramLinkTarget() {
|
||||
};
|
||||
}
|
||||
|
||||
const chipTokenComponents = {
|
||||
...markdownComponents,
|
||||
[CHIP_TOKEN_TAG]: ({ children }: { children?: ReactNode }) => (
|
||||
<span className={agentComposerTokenClassName}>{children}</span>
|
||||
),
|
||||
} as Components;
|
||||
const chipTokenAllowedTags = { [CHIP_TOKEN_TAG]: [] };
|
||||
|
||||
export const MemoizedMarkdown = memo(
|
||||
({
|
||||
content,
|
||||
classNames,
|
||||
streaming = false,
|
||||
chipTokens,
|
||||
}: {
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
classNames?: string;
|
||||
/** Exact `/command` and `@path` tokens to render as composer chips. */
|
||||
chipTokens?: ReadonlySet<string>;
|
||||
}) => {
|
||||
const {
|
||||
captureDiagramLink,
|
||||
@@ -387,6 +401,16 @@ export const MemoizedMarkdown = memo(
|
||||
recordDiagramLinkPress,
|
||||
setPendingUrl,
|
||||
} = useDiagramLinkTarget();
|
||||
const remarkPlugins = useMemo(
|
||||
() =>
|
||||
chipTokens?.size
|
||||
? [
|
||||
...Object.values(defaultRemarkPlugins),
|
||||
[remarkChipTokens, { tokens: [...chipTokens] }],
|
||||
]
|
||||
: undefined,
|
||||
[chipTokens],
|
||||
);
|
||||
|
||||
return (
|
||||
// `contents` keeps the wrapper out of layout while still receiving
|
||||
@@ -399,8 +423,9 @@ export const MemoizedMarkdown = memo(
|
||||
onPointerDownCapture={recordDiagramLinkPress}
|
||||
>
|
||||
<Streamdown
|
||||
allowedTags={remarkPlugins ? chipTokenAllowedTags : undefined}
|
||||
className={cn("cline-markdown", classNames)}
|
||||
components={markdownComponents}
|
||||
components={remarkPlugins ? chipTokenComponents : markdownComponents}
|
||||
controls={agentMarkdownControls}
|
||||
dir="auto"
|
||||
isAnimating={streaming}
|
||||
@@ -409,6 +434,7 @@ export const MemoizedMarkdown = memo(
|
||||
normalizeHtmlIndentation
|
||||
parseIncompleteMarkdown={streaming}
|
||||
plugins={streamdownPlugins}
|
||||
remarkPlugins={remarkPlugins}
|
||||
>
|
||||
{content}
|
||||
</Streamdown>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import {
|
||||
act,
|
||||
Fragment,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
StrictMode,
|
||||
} from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
@@ -200,6 +205,7 @@ async function renderVoiceComposer({
|
||||
executionTarget,
|
||||
onAttachFiles = vi.fn(),
|
||||
sessionId,
|
||||
strictMode = false,
|
||||
}: {
|
||||
attachments?: Parameters<typeof ChatInputBar>[0]["attachments"];
|
||||
model?: string;
|
||||
@@ -214,47 +220,51 @@ async function renderVoiceComposer({
|
||||
executionTarget?: "cloud" | "local";
|
||||
onAttachFiles?: Parameters<typeof ChatInputBar>[0]["onAttachFiles"];
|
||||
sessionId?: string;
|
||||
strictMode?: boolean;
|
||||
} = {}) {
|
||||
const Wrapper = strictMode ? StrictMode : Fragment;
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider value={workspaceValue}>
|
||||
<ChatInputBar
|
||||
environmentId="local"
|
||||
workspaceRoot="/workspace/cline"
|
||||
readOnly={readOnly}
|
||||
executionTarget={executionTarget}
|
||||
sessionId={sessionId}
|
||||
attachments={attachments}
|
||||
gitBranch="main"
|
||||
hasRunningAgents={hasRunningAgents}
|
||||
mode="act"
|
||||
model={model}
|
||||
onAbort={onAbort}
|
||||
onAttachFiles={onAttachFiles}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPromptInputChange={onPromptInputChange}
|
||||
onProviderChange={vi.fn()}
|
||||
onReasoningChange={vi.fn()}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onRemovePromptInQueue={vi.fn()}
|
||||
onSend={onSend}
|
||||
onSteerPromptInQueue={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
promptDraft={{ version: promptVersion, value: prompt }}
|
||||
promptsInQueue={[]}
|
||||
provider="cline"
|
||||
reasoningEffort="low"
|
||||
status={status}
|
||||
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
|
||||
thinking
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
<Wrapper>
|
||||
<WorkspaceProvider value={workspaceValue}>
|
||||
<ChatInputBar
|
||||
environmentId="local"
|
||||
workspaceRoot="/workspace/cline"
|
||||
readOnly={readOnly}
|
||||
executionTarget={executionTarget}
|
||||
sessionId={sessionId}
|
||||
attachments={attachments}
|
||||
gitBranch="main"
|
||||
hasRunningAgents={hasRunningAgents}
|
||||
mode="act"
|
||||
model={model}
|
||||
onAbort={onAbort}
|
||||
onAttachFiles={onAttachFiles}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPromptInputChange={onPromptInputChange}
|
||||
onProviderChange={vi.fn()}
|
||||
onReasoningChange={vi.fn()}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onRemovePromptInQueue={vi.fn()}
|
||||
onSend={onSend}
|
||||
onSteerPromptInQueue={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
promptDraft={{ version: promptVersion, value: prompt }}
|
||||
promptsInQueue={[]}
|
||||
provider="cline"
|
||||
reasoningEffort="low"
|
||||
status={status}
|
||||
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
|
||||
thinking
|
||||
/>
|
||||
</WorkspaceProvider>
|
||||
</Wrapper>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
@@ -465,6 +475,73 @@ describe("ChatInputBar", () => {
|
||||
expect(container.textContent).not.toContain("Session A plugin");
|
||||
});
|
||||
|
||||
it("loads commands up front so a skill chips before the menu opens", async () => {
|
||||
const invoke = vi
|
||||
.spyOn(desktopClient, "invoke")
|
||||
.mockImplementation(async (name) =>
|
||||
name === "list_plugin_commands"
|
||||
? { status: "ready", commands: [] }
|
||||
: {
|
||||
runtimeCommands: [
|
||||
{ name: "publish-skill", kind: "skill", description: "" },
|
||||
],
|
||||
},
|
||||
);
|
||||
const instructionRequests = () =>
|
||||
invoke.mock.calls.filter(
|
||||
([name]) => name === "list_user_instruction_configs",
|
||||
).length;
|
||||
// StrictMode replays the mount effect; the cancelled first load must not
|
||||
// count as loaded (Next dev renders the app this way).
|
||||
await renderVoiceComposer({
|
||||
prompt: "/publish-skill ship it",
|
||||
executionTarget: "local",
|
||||
strictMode: true,
|
||||
});
|
||||
expect(container.querySelector("#slash-command-suggestions")).toBeNull();
|
||||
const chip = container.querySelector("[aria-hidden] span");
|
||||
expect(chip?.textContent).toBe("/publish-skill");
|
||||
|
||||
const loadsBeforeMenu = instructionRequests();
|
||||
await renderVoiceComposer({
|
||||
prompt: "/pub",
|
||||
promptVersion: 1,
|
||||
executionTarget: "local",
|
||||
strictMode: true,
|
||||
});
|
||||
expect(instructionRequests()).toBeGreaterThan(loadsBeforeMenu);
|
||||
});
|
||||
|
||||
it("retries a plugin catalog cut off by closing the menu", async () => {
|
||||
const firstPluginLoad = deferred<unknown>();
|
||||
let pluginRequests = 0;
|
||||
vi.spyOn(desktopClient, "invoke").mockImplementation(async (name) => {
|
||||
if (name !== "list_plugin_commands") return { runtimeCommands: [] };
|
||||
pluginRequests++;
|
||||
return pluginRequests === 1
|
||||
? firstPluginLoad.promise
|
||||
: { status: "ready", commands: [{ name: "deploy" }] };
|
||||
});
|
||||
await renderVoiceComposer({ prompt: "/dep", executionTarget: "local" });
|
||||
await renderVoiceComposer({
|
||||
prompt: "/deploy now",
|
||||
promptVersion: 1,
|
||||
executionTarget: "local",
|
||||
});
|
||||
const textarea = container.querySelector("textarea");
|
||||
// Moving the caret past the command closes the menu mid-load.
|
||||
await act(async () => {
|
||||
textarea?.setSelectionRange(11, 11);
|
||||
textarea?.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true }));
|
||||
});
|
||||
expect(container.querySelector("#slash-command-suggestions")).toBeNull();
|
||||
await vi.waitFor(() =>
|
||||
expect(container.querySelector("[aria-hidden] span")?.textContent).toBe(
|
||||
"/deploy",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes commands on reopen without resetting cached suggestions", async () => {
|
||||
const updated = deferred<{
|
||||
runtimeCommands: Array<{
|
||||
@@ -820,6 +897,21 @@ describe("ChatInputBar", () => {
|
||||
expect(suggestions?.textContent).toContain("/compact");
|
||||
});
|
||||
|
||||
it("opens the slash menu after leading whitespace", async () => {
|
||||
await renderVoiceComposer({ prompt: " /comp", executionTarget: "local" });
|
||||
expect(
|
||||
container.querySelector("#slash-command-suggestions"),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not open the slash menu mid-sentence", async () => {
|
||||
await renderVoiceComposer({
|
||||
prompt: "please /comp",
|
||||
executionTarget: "local",
|
||||
});
|
||||
expect(container.querySelector("#slash-command-suggestions")).toBeNull();
|
||||
});
|
||||
|
||||
it("scrolls the arrow-key selected slash command into view", async () => {
|
||||
await renderVoiceComposer({ prompt: "/", executionTarget: "local" });
|
||||
const textarea = container.querySelector("textarea");
|
||||
@@ -1177,9 +1269,10 @@ describe("ChatInputBar", () => {
|
||||
const promptInput = container.querySelector<HTMLTextAreaElement>(
|
||||
'textarea[role="combobox"]',
|
||||
);
|
||||
expect(promptInput?.parentElement?.className).toContain("min-h-16");
|
||||
expect(promptInput?.parentElement?.className).toContain("items-end");
|
||||
expect(promptInput?.className).toContain("self-start");
|
||||
const field = promptInput?.parentElement?.parentElement;
|
||||
expect(field?.className).toContain("min-h-16");
|
||||
expect(field?.className).toContain("items-end");
|
||||
expect(promptInput?.parentElement?.className).toContain("self-start");
|
||||
});
|
||||
|
||||
it("inserts cumulative browser fallback transcripts without duplicating text", async () => {
|
||||
@@ -1744,9 +1837,10 @@ describe("ChatInputBar", () => {
|
||||
const stopTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Stop agent"]',
|
||||
);
|
||||
expect(promptInput?.parentElement?.className).toContain("items-start");
|
||||
expect(promptInput?.parentElement?.contains(sendTrigger)).toBe(true);
|
||||
expect(promptInput?.parentElement?.contains(stopTrigger)).toBe(true);
|
||||
const field = promptInput?.parentElement?.parentElement;
|
||||
expect(field?.className).toContain("items-start");
|
||||
expect(field?.contains(sendTrigger)).toBe(true);
|
||||
expect(field?.contains(stopTrigger)).toBe(true);
|
||||
// The mic button is hidden for now (SHOW_VOICE_INPUT_BUTTON).
|
||||
expect(speechTrigger).toBeNull();
|
||||
expect(sendTrigger?.parentElement?.className).toContain("self-end");
|
||||
|
||||
@@ -47,6 +47,12 @@ import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { imageFilesFromClipboard } from "@/lib/clipboard-images";
|
||||
import { cloudRepositoryLabel } from "@/lib/cloud-repositories";
|
||||
import {
|
||||
addKnownMentionPaths,
|
||||
getComposerHighlights,
|
||||
setKnownSlashCommands,
|
||||
useKnownComposerTokens,
|
||||
} from "@/lib/composer-tokens";
|
||||
import { desktopClient, writeDesktopDebugLog } from "@/lib/desktop-client";
|
||||
import {
|
||||
buildModelPickerData,
|
||||
@@ -260,8 +266,9 @@ function getActiveSlash(input: string, cursor: number): ActiveSlash | null {
|
||||
if (slashIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
// Slash must be at the start or preceded by whitespace.
|
||||
if (slashIndex > 0 && !/\s/.test(left[slashIndex - 1] ?? "")) {
|
||||
// The sidecar only runs a command that starts the trimmed prompt, so a
|
||||
// mid-sentence slash is plain text.
|
||||
if (left.slice(0, slashIndex).trim() !== "") {
|
||||
return null;
|
||||
}
|
||||
const query = left.slice(slashIndex + 1);
|
||||
@@ -270,12 +277,6 @@ function getActiveSlash(input: string, cursor: number): ActiveSlash | null {
|
||||
if (/\s/.test(query)) {
|
||||
return null;
|
||||
}
|
||||
// Don't open slash mode if there's already a completed slash command earlier in the input.
|
||||
const firstSlashCommandRegex = /(^|\s)\/[a-zA-Z0-9_.-]+\s/;
|
||||
const textBeforeCurrentSlash = input.slice(0, slashIndex);
|
||||
if (firstSlashCommandRegex.test(textBeforeCurrentSlash)) {
|
||||
return null;
|
||||
}
|
||||
return { slashIndex, query };
|
||||
}
|
||||
|
||||
@@ -632,6 +633,7 @@ function ChatInputBarImpl({
|
||||
const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0);
|
||||
const mentionResultsCacheRef = useRef(new Map<string, string[]>());
|
||||
const mentionLastRequestKeyRef = useRef<string | null>(null);
|
||||
const knownTokens = useKnownComposerTokens();
|
||||
|
||||
// ---- Slash command state ----
|
||||
const activeSlash = useMemo(
|
||||
@@ -695,6 +697,19 @@ function ChatInputBarImpl({
|
||||
const slashLoading = pluginLoading || instructionLoading;
|
||||
const slashError = pluginError ?? instructionError;
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
|
||||
const loadedCommandCatalogKeyRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
setKnownSlashCommands(slashCommands.map((command) => command.name));
|
||||
}, [slashCommands]);
|
||||
const composerHighlights = useMemo(
|
||||
() =>
|
||||
executionTarget === "local"
|
||||
? getComposerHighlights(promptInput, knownTokens.commandNames, (path) =>
|
||||
knownTokens.mentionPaths.has(path),
|
||||
)
|
||||
: undefined,
|
||||
[executionTarget, promptInput, knownTokens],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -970,6 +985,7 @@ function ChatInputBarImpl({
|
||||
const nextResults = Array.isArray(results) ? results : [];
|
||||
mentionResultsCacheRef.current.set(requestKey, nextResults);
|
||||
setMentionFiles(nextResults);
|
||||
addKnownMentionPaths(nextResults);
|
||||
setMentionSelectedIndex(0);
|
||||
} catch {
|
||||
if (cancelled) {
|
||||
@@ -1047,16 +1063,27 @@ function ChatInputBarImpl({
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}, [mentionOpen, mentionSelectedIndex]);
|
||||
|
||||
// Refresh user and plugin commands whenever the slash menu opens, keeping
|
||||
// the last results while fetching so suggestions do not flash away. Each
|
||||
// request settles independently so one failure cannot hide the other.
|
||||
// Load commands once per workspace/session up front (sent messages chip
|
||||
// them too), then refresh whenever the slash menu opens, keeping the last
|
||||
// results while fetching so suggestions do not flash away. Each request
|
||||
// settles independently so one failure cannot hide the other.
|
||||
useEffect(() => {
|
||||
if (!slashOpen) {
|
||||
const catalogKey = `${environmentId}\u0000${workspaceRoot}\u0000${sessionId ?? ""}`;
|
||||
if (!slashOpen && loadedCommandCatalogKeyRef.current === catalogKey) {
|
||||
setInstructionLoading(false);
|
||||
setPluginLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
// Marked loaded only once both catalogs arrive: a cancelled (e.g. a
|
||||
// StrictMode effect replay), failed, or half-finished load must be
|
||||
// retried rather than leaving commands unchipped.
|
||||
let pendingCatalogs = 2;
|
||||
const catalogArrived = () => {
|
||||
pendingCatalogs -= 1;
|
||||
if (pendingCatalogs === 0)
|
||||
loadedCommandCatalogKeyRef.current = catalogKey;
|
||||
};
|
||||
setInstructionLoading(true);
|
||||
setPluginLoading(true);
|
||||
desktopClient
|
||||
@@ -1066,6 +1093,7 @@ function ChatInputBarImpl({
|
||||
})
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
catalogArrived();
|
||||
setInstructionResponse({ workspaceRoot, environmentId, response });
|
||||
setInstructionError(undefined);
|
||||
})
|
||||
@@ -1083,6 +1111,7 @@ function ChatInputBarImpl({
|
||||
})
|
||||
.then((catalog) => {
|
||||
if (cancelled) return;
|
||||
if (catalog.status !== "error") catalogArrived();
|
||||
setPluginResponse({
|
||||
workspaceRoot,
|
||||
environmentId,
|
||||
@@ -1299,6 +1328,7 @@ function ChatInputBarImpl({
|
||||
}
|
||||
aria-expanded={slashOpen || mentionOpen}
|
||||
aria-haspopup="listbox"
|
||||
highlights={composerHighlights}
|
||||
variant={variant}
|
||||
onChange={(e) => {
|
||||
if (speechInputActive) return;
|
||||
|
||||
+40
-5
@@ -16,7 +16,7 @@ import {
|
||||
SplitIcon,
|
||||
UndoIcon,
|
||||
} from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { memo, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { resolveCredentialFailureAction } from "@/hooks/chat-session/helpers";
|
||||
import type {
|
||||
@@ -24,6 +24,10 @@ import type {
|
||||
ChatMessageImage,
|
||||
ChatMessageMedia,
|
||||
} from "@/lib/chat-schema";
|
||||
import {
|
||||
getComposerHighlights,
|
||||
useKnownComposerTokens,
|
||||
} from "@/lib/composer-tokens";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MemoizedMarkdown } from "../../../ui/markdown";
|
||||
import { formatChatMessageContent } from "../message-content";
|
||||
@@ -68,6 +72,30 @@ function MessageImages({
|
||||
);
|
||||
}
|
||||
|
||||
// Chips the same tokens the composer did: known commands, plus mentions the
|
||||
// composer saw in search results or the history recorded as attached files.
|
||||
function UserMessageMarkdown({
|
||||
content,
|
||||
attachedFiles,
|
||||
}: {
|
||||
content: string;
|
||||
attachedFiles?: string[];
|
||||
}) {
|
||||
const { commandNames, mentionPaths } = useKnownComposerTokens();
|
||||
const tokenKey = getComposerHighlights(
|
||||
content,
|
||||
commandNames,
|
||||
(path) => mentionPaths.has(path) || Boolean(attachedFiles?.includes(path)),
|
||||
)
|
||||
.map(({ start, end }) => content.slice(start, end))
|
||||
.join("\n");
|
||||
const chipTokens = useMemo(
|
||||
() => new Set(tokenKey ? tokenKey.split("\n") : []),
|
||||
[tokenKey],
|
||||
);
|
||||
return <MemoizedMarkdown chipTokens={chipTokens} content={content} />;
|
||||
}
|
||||
|
||||
function MessageMedia({ media }: { media: ChatMessageMedia[] }) {
|
||||
return (
|
||||
<div className="flex max-w-2xl flex-col gap-2">
|
||||
@@ -258,10 +286,17 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
|
||||
{displayContent ? (
|
||||
<div className="min-w-0 max-w-full wrap-break-word">
|
||||
<MemoizedMarkdown
|
||||
content={displayContent}
|
||||
streaming={isStreaming && message.role === "assistant"}
|
||||
/>
|
||||
{isUser ? (
|
||||
<UserMessageMarkdown
|
||||
attachedFiles={message.meta?.attachedFiles}
|
||||
content={displayContent}
|
||||
/>
|
||||
) : (
|
||||
<MemoizedMarkdown
|
||||
content={displayContent}
|
||||
streaming={isStreaming && message.role === "assistant"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -98,6 +98,11 @@ export const ChatMessageSchema = z.object({
|
||||
providerAuth: ProviderAuthInfoSchema.optional(),
|
||||
modelId: z.string().optional(),
|
||||
userRunSpan: z.number().int().nonnegative().optional(),
|
||||
/**
|
||||
* Files attached to a user message (resolved `@` mentions), relative
|
||||
* to the session's mention base directory when inside it.
|
||||
*/
|
||||
attachedFiles: z.array(z.string()).optional(),
|
||||
runCount: z.number().int().positive().optional(),
|
||||
checkpoint: z
|
||||
.object({
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getComposerHighlights,
|
||||
normalizeMentionPath,
|
||||
stripMentionTrailingPunctuation,
|
||||
} from "./composer-tokens";
|
||||
|
||||
describe("getComposerHighlights", () => {
|
||||
it("highlights only a leading, known slash command", () => {
|
||||
const highlight = (input: string) =>
|
||||
getComposerHighlights(
|
||||
input,
|
||||
new Set(["compact", "release"]),
|
||||
() => false,
|
||||
);
|
||||
expect(highlight(" /Release v2")).toEqual([{ start: 2, end: 10 }]);
|
||||
expect(highlight("/compact")).toEqual([{ start: 0, end: 8 }]);
|
||||
expect(highlight("/comp")).toEqual([]);
|
||||
expect(highlight("/compactly")).toEqual([]);
|
||||
expect(highlight("please /compact")).toEqual([]);
|
||||
});
|
||||
|
||||
it("highlights mentions of known workspace files", () => {
|
||||
const known = new Set(["src/app.ts", "README.md"]);
|
||||
const highlight = (input: string) =>
|
||||
getComposerHighlights(input, new Set(["compact"]), (path) =>
|
||||
known.has(path),
|
||||
);
|
||||
expect(highlight("/compact @src/app.ts, then @README.md")).toEqual([
|
||||
{ start: 0, end: 8 },
|
||||
{ start: 9, end: 20 },
|
||||
{ start: 27, end: 37 },
|
||||
]);
|
||||
expect(highlight("@src/app")).toEqual([]);
|
||||
expect(highlight("mail me@src/app.ts")).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches normalized mention paths exactly, never by suffix", () => {
|
||||
const attached = new Set(["docs/README.md"]);
|
||||
const highlight = (input: string) =>
|
||||
getComposerHighlights(input, new Set(), (path) => attached.has(path));
|
||||
expect(highlight("Review @README.md and @docs/README.md")).toEqual([
|
||||
{ start: 22, end: 37 },
|
||||
]);
|
||||
expect(highlight("see @./docs/README.md!")).toEqual([
|
||||
{ start: 4, end: 21 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips trailing punctuation without backtracking on long runs", () => {
|
||||
expect(stripMentionTrailingPunctuation("src/app.ts),.")).toBe("src/app.ts");
|
||||
const adversarial = `${"!".repeat(50_000)}file.ts`;
|
||||
const started = performance.now();
|
||||
expect(stripMentionTrailingPunctuation(adversarial)).toBe(adversarial);
|
||||
expect(performance.now() - started).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeMentionPath", () => {
|
||||
it("collapses dot segments and backslashes", () => {
|
||||
expect(normalizeMentionPath("./docs/../src\\app.ts")).toBe("src/app.ts");
|
||||
expect(normalizeMentionPath("/abs//path/./a.ts")).toBe("/abs/path/a.ts");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type TokenRange = { start: number; end: number };
|
||||
|
||||
const MENTION_TRAILING_PUNCTUATION = new Set("),.:;!?`'\"");
|
||||
|
||||
/**
|
||||
* Drops trailing punctuation like the runtime's mention parser. A backwards
|
||||
* scan rather than an anchored regex, which backtracks quadratically on long
|
||||
* punctuation runs.
|
||||
*/
|
||||
export function stripMentionTrailingPunctuation(token: string): string {
|
||||
let end = token.length;
|
||||
while (end > 0 && MENTION_TRAILING_PUNCTUATION.has(token.charAt(end - 1))) {
|
||||
end -= 1;
|
||||
}
|
||||
return token.slice(0, end);
|
||||
}
|
||||
|
||||
/** Collapses `./`, `..` and backslashes so `@./src/a.ts` compares as `src/a.ts`. */
|
||||
export function normalizeMentionPath(path: string): string {
|
||||
const segments: string[] = [];
|
||||
for (const segment of path.replace(/\\/g, "/").split("/")) {
|
||||
if (segment === "" || segment === ".") continue;
|
||||
if (segment === ".." && segments.length > 0 && segments.at(-1) !== "..") {
|
||||
segments.pop();
|
||||
} else {
|
||||
segments.push(segment);
|
||||
}
|
||||
}
|
||||
return `${path.startsWith("/") ? "/" : ""}${segments.join("/")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranges of `input` that the sidecar acts on: a leading `/command` (it only
|
||||
* runs a command that starts the trimmed prompt) and `@path` mentions of real
|
||||
* workspace files (it only attaches mentions that resolve).
|
||||
*/
|
||||
export function getComposerHighlights(
|
||||
input: string,
|
||||
commandNames: ReadonlySet<string>,
|
||||
isKnownMentionPath: (path: string) => boolean,
|
||||
): TokenRange[] {
|
||||
const highlights: TokenRange[] = [];
|
||||
const command = /^(\s*)\/(\S+)(?=\s|$)/.exec(input);
|
||||
if (command && commandNames.has(command[2].toLowerCase())) {
|
||||
highlights.push({ start: command[1].length, end: command[0].length });
|
||||
}
|
||||
for (const match of input.matchAll(/(^|\s)@(\S+)/g)) {
|
||||
const path = stripMentionTrailingPunctuation(match[2]);
|
||||
if (!path || !isKnownMentionPath(normalizeMentionPath(path))) continue;
|
||||
const start = match.index + match[1].length;
|
||||
highlights.push({ start, end: start + 1 + path.length });
|
||||
}
|
||||
return highlights;
|
||||
}
|
||||
|
||||
type KnownComposerTokens = {
|
||||
/** Lower-cased slash command names. */
|
||||
commandNames: ReadonlySet<string>;
|
||||
/** Workspace-relative paths seen in mention search results, normalized. */
|
||||
mentionPaths: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
// Module-level so sent messages in the transcript can chip the same tokens the
|
||||
// composer recognized, without threading them through every message.
|
||||
let known: KnownComposerTokens = {
|
||||
commandNames: new Set(),
|
||||
mentionPaths: new Set(),
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function update(next: KnownComposerTokens) {
|
||||
known = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function setKnownSlashCommands(names: string[]) {
|
||||
const commandNames = new Set(names.map((name) => name.toLowerCase()));
|
||||
if (
|
||||
commandNames.size === known.commandNames.size &&
|
||||
[...commandNames].every((name) => known.commandNames.has(name))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
update({ ...known, commandNames });
|
||||
}
|
||||
|
||||
export function addKnownMentionPaths(paths: string[]) {
|
||||
const normalized = paths.map(normalizeMentionPath);
|
||||
if (normalized.every((path) => known.mentionPaths.has(path))) return;
|
||||
update({
|
||||
...known,
|
||||
mentionPaths: new Set([...known.mentionPaths, ...normalized]),
|
||||
});
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function useKnownComposerTokens(): KnownComposerTokens {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => known,
|
||||
() => known,
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useContext,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
/** Presentation only. Hosts retain drafts, input events, menus, and runtime actions. */
|
||||
@@ -96,28 +97,102 @@ export const AgentComposerField = forwardRef<
|
||||
);
|
||||
});
|
||||
|
||||
/** Chip style for composer tokens; hosts reuse it where sent messages echo them. */
|
||||
export const agentComposerTokenClassName =
|
||||
"-mx-0.5 rounded-cline-ui-md bg-cline-ui-primary/25 px-0.5 py-px inset-ring inset-ring-cline-ui-primary/50";
|
||||
|
||||
export type AgentComposerTextareaProps = ComponentPropsWithoutRef<"textarea"> &
|
||||
VariantProps;
|
||||
VariantProps & {
|
||||
/**
|
||||
* Sorted, non-overlapping `[start, end)` ranges of `value` drawn as inline
|
||||
* tokens (slash commands, file mentions). Backspace at a token's end or
|
||||
* Delete at its start removes the whole token.
|
||||
*/
|
||||
highlights?: { start: number; end: number }[];
|
||||
};
|
||||
|
||||
export const AgentComposerTextarea = forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
AgentComposerTextareaProps
|
||||
>(function AgentComposerTextarea(
|
||||
{ variant: variantProp, className, ...props },
|
||||
{
|
||||
variant: variantProp,
|
||||
className,
|
||||
highlights,
|
||||
onKeyDown,
|
||||
onScroll,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const variant = useAgentComposerVariant(variantProp);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const value = typeof props.value === "string" ? props.value : "";
|
||||
let renderedUpTo = 0;
|
||||
// The wrapper is always rendered so toggling the highlight never remounts
|
||||
// (and blurs) the textarea mid-typing. Both layers reserve the scrollbar
|
||||
// gutter so text wraps identically once the textarea scrolls.
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
ref={ref}
|
||||
<div
|
||||
className={clsx(
|
||||
variant === "welcome"
|
||||
? "field-sizing-content flex-1 resize-none overflow-y-auto bg-transparent text-cline-ui-sm leading-5 text-cline-ui-foreground placeholder:text-cline-ui-muted-foreground outline-none self-start"
|
||||
: "field-sizing-content flex-1 resize-none overflow-y-auto bg-transparent text-cline-ui-sm leading-5 text-cline-ui-foreground placeholder:text-cline-ui-muted-foreground outline-none",
|
||||
className,
|
||||
"relative flex min-w-0 flex-1",
|
||||
variant === "welcome" && "self-start",
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{highlights && highlights.length > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 overflow-y-hidden [scrollbar-gutter:stable] whitespace-pre-wrap break-words text-cline-ui-sm leading-5 text-transparent"
|
||||
>
|
||||
<div style={{ transform: `translateY(${-scrollTop}px)` }}>
|
||||
{highlights.map(({ start, end }) => {
|
||||
const before = value.slice(renderedUpTo, start);
|
||||
renderedUpTo = end;
|
||||
return [
|
||||
before,
|
||||
<span className={agentComposerTokenClassName} key={start}>
|
||||
{value.slice(start, end)}
|
||||
</span>,
|
||||
];
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
{...props}
|
||||
ref={ref}
|
||||
onKeyDown={(event) => {
|
||||
onKeyDown?.(event);
|
||||
const textarea = event.currentTarget;
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.nativeEvent.isComposing ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
textarea.selectionStart !== textarea.selectionEnd
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const caret = textarea.selectionStart;
|
||||
const token = highlights?.find((range) =>
|
||||
event.key === "Backspace"
|
||||
? range.end === caret
|
||||
: event.key === "Delete" && range.start === caret,
|
||||
);
|
||||
// Selecting the token lets the native delete remove it, keeping undo intact.
|
||||
if (token) textarea.setSelectionRange(token.start, token.end);
|
||||
}}
|
||||
onScroll={(event) => {
|
||||
setScrollTop(event.currentTarget.scrollTop);
|
||||
onScroll?.(event);
|
||||
}}
|
||||
className={clsx(
|
||||
"relative field-sizing-content flex-1 resize-none overflow-y-auto [scrollbar-gutter:stable] bg-transparent text-cline-ui-sm leading-5 text-cline-ui-foreground placeholder:text-cline-ui-muted-foreground outline-none",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export {
|
||||
AgentComposerTextarea,
|
||||
type AgentComposerTextareaProps,
|
||||
type AgentComposerVariant,
|
||||
agentComposerTokenClassName,
|
||||
} from "./agent-composer.js";
|
||||
export {
|
||||
AgentContextUsage,
|
||||
|
||||
@@ -170,9 +170,68 @@ describe("AgentComposer", () => {
|
||||
const [stop, send] = container.querySelectorAll("button");
|
||||
expect(body?.className).toBe("px-4 py-3 pb-2 pt-4");
|
||||
expect(field?.classList.contains("items-end")).toBe(true);
|
||||
expect(textarea?.classList.contains("self-start")).toBe(true);
|
||||
expect(textarea?.parentElement?.classList.contains("self-start")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(actions?.classList.contains("self-end")).toBe(false);
|
||||
expect(stop.classList.contains("rounded-cline-ui-md")).toBe(true);
|
||||
expect(send.classList.contains("rounded-full")).toBe(true);
|
||||
});
|
||||
|
||||
it("draws highlighted ranges behind the textarea without remounting it", async () => {
|
||||
const textarea = createRef<HTMLTextAreaElement>();
|
||||
const render = (value: string, highlighted: boolean) =>
|
||||
root.render(
|
||||
<AgentComposerTextarea
|
||||
ref={textarea}
|
||||
value={value}
|
||||
onChange={() => {}}
|
||||
highlights={
|
||||
highlighted
|
||||
? [
|
||||
{ start: 2, end: 10 },
|
||||
{ start: 15, end: 20 },
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
);
|
||||
await act(async () => render(" /compact", false));
|
||||
const original = textarea.current;
|
||||
expect(container.querySelector("[aria-hidden]")).toBeNull();
|
||||
|
||||
await act(async () => render(" /compact see @a.ts now", true));
|
||||
const backdrop = container.querySelector("[aria-hidden]");
|
||||
expect(backdrop?.textContent).toBe(" /compact see @a.ts");
|
||||
expect(
|
||||
[...(backdrop?.querySelectorAll("span") ?? [])].map(
|
||||
(span) => span.textContent,
|
||||
),
|
||||
).toEqual(["/compact", "@a.ts"]);
|
||||
expect(textarea.current).toBe(original);
|
||||
});
|
||||
|
||||
it("selects a whole token for Backspace at its end and Delete at its start", async () => {
|
||||
const textarea = createRef<HTMLTextAreaElement>();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<AgentComposerTextarea
|
||||
ref={textarea}
|
||||
value="see @a.ts now"
|
||||
onChange={() => {}}
|
||||
highlights={[{ start: 4, end: 9 }]}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const input = textarea.current as HTMLTextAreaElement;
|
||||
const press = (key: string, caret: number) => {
|
||||
input.setSelectionRange(caret, caret);
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
|
||||
return [input.selectionStart, input.selectionEnd];
|
||||
};
|
||||
expect(press("Backspace", 9)).toEqual([4, 9]);
|
||||
expect(press("Delete", 4)).toEqual([4, 9]);
|
||||
expect(press("Backspace", 10)).toEqual([10, 10]);
|
||||
expect(press("Backspace", 7)).toEqual([7, 7]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user