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:
Saoud Rizwan
2026-10-23 18:46:43 -07:00
committed by GitHub
co-authored by Cursor Agent
parent d35ad45e49
commit 1e39ae2f2d
17 changed files with 862 additions and 82 deletions
+1 -1
View File
@@ -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;
@@ -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,
);
}
+85 -10
View File
@@ -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>
);
});
+1
View File
@@ -45,6 +45,7 @@ export {
AgentComposerTextarea,
type AgentComposerTextareaProps,
type AgentComposerVariant,
agentComposerTokenClassName,
} from "./agent-composer.js";
export {
AgentContextUsage,
+60 -1
View File
@@ -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]);
});
});