History

Add composer path tagging with workspace entry search

- add `projects.searchEntries` API, contracts, and server-side indexed workspace search - add composer trigger logic for `@` path suggestions and `/model` command completion - include tagged paths in sent prompt/user-visible text and cover behavior with server/web tests

Julius Marminge committed 8 months ago

Commit ca1869c1 parent ba4a62e12 files · +845 −8Browse files

Unified · Split

12 files
  1. apps/server/src/workspaceEntries.test.ts +62 −0
  2. apps/server/src/workspaceEntries.ts +186 −0
  3. apps/server/src/wsServer.test.ts +32 −0
  4. apps/server/src/wsServer.ts +4 −0
  5. apps/web/src/components/ChatView.tsx +295 −8
  6. apps/web/src/composer-logic.test.ts +72 −0
  7. apps/web/src/composer-logic.ts +128 −0
  8. apps/web/src/wsNativeApi.ts +1 −0
  9. packages/contracts/src/ipc.ts +3 −0
  10. packages/contracts/src/project.test.ts +36 −0
  11. packages/contracts/src/project.ts +25 −0
  12. packages/contracts/src/ws.ts +1 −0
apps/server/src/workspaceEntries.test.ts+62−0
@@ -0,0 +1,62 @@
1+import fs from "node:fs";
2+import os from "node:os";
3+import path from "node:path";
4+
5+import { afterEach, describe, expect, it } from "vitest";
6+
7+import { searchWorkspaceEntries } from "./workspaceEntries";
8+
9+const tempDirs: string[] = [];
10+
11+function makeTempDir(prefix: string): string {
12+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
13+ tempDirs.push(dir);
14+ return dir;
15+}
16+
17+function writeFile(cwd: string, relativePath: string, contents = ""): void {
18+ const absolutePath = path.join(cwd, relativePath);
19+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
20+ fs.writeFileSync(absolutePath, contents, "utf8");
21+}
22+
23+describe("searchWorkspaceEntries", () => {
24+ afterEach(() => {
25+ for (const dir of tempDirs.splice(0, tempDirs.length)) {
26+ fs.rmSync(dir, { recursive: true, force: true });
27+ }
28+ });
29+
30+ it("returns files and directories relative to cwd", async () => {
31+ const cwd = makeTempDir("t3code-workspace-entries-");
32+ writeFile(cwd, "src/components/Composer.tsx");
33+ writeFile(cwd, "src/index.ts");
34+ writeFile(cwd, "README.md");
35+ writeFile(cwd, ".git/HEAD");
36+ writeFile(cwd, "node_modules/pkg/index.js");
37+
38+ const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });
39+ const paths = result.entries.map((entry) => entry.path);
40+
41+ expect(paths).toContain("src");
42+ expect(paths).toContain("src/components");
43+ expect(paths).toContain("src/components/Composer.tsx");
44+ expect(paths).toContain("README.md");
45+ expect(paths.some((entryPath) => entryPath.startsWith(".git"))).toBe(false);
46+ expect(paths.some((entryPath) => entryPath.startsWith("node_modules"))).toBe(false);
47+ expect(result.truncated).toBe(false);
48+ });
49+
50+ it("filters and ranks entries by query", async () => {
51+ const cwd = makeTempDir("t3code-workspace-query-");
52+ writeFile(cwd, "src/components/Composer.tsx");
53+ writeFile(cwd, "src/components/composePrompt.ts");
54+ writeFile(cwd, "docs/composition.md");
55+
56+ const result = await searchWorkspaceEntries({ cwd, query: "compo", limit: 5 });
57+
58+ expect(result.entries.length).toBeGreaterThan(0);
59+ expect(result.entries.some((entry) => entry.path === "src/components")).toBe(true);
60+ expect(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))).toBe(true);
61+ });
62+});
apps/server/src/workspaceEntries.ts+186−0
@@ -0,0 +1,186 @@
1+import fs from "node:fs/promises";
2+import path from "node:path";
3+
4+import {
5+ projectSearchEntriesInputSchema,
6+ type ProjectEntry,
7+ type ProjectSearchEntriesInput,
8+ type ProjectSearchEntriesResult,
9+} from "@t3tools/contracts";
10+
11+const WORKSPACE_CACHE_TTL_MS = 15_000;
12+const WORKSPACE_CACHE_MAX_KEYS = 4;
13+const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
14+const IGNORED_DIRECTORY_NAMES = new Set([
15+ ".git",
16+ "node_modules",
17+ ".next",
18+ ".turbo",
19+ "dist",
20+ "build",
21+ "out",
22+ ".cache",
23+]);
24+
25+interface WorkspaceIndex {
26+ scannedAt: number;
27+ entries: ProjectEntry[];
28+ truncated: boolean;
29+}
30+
31+const workspaceIndexCache = new Map<string, WorkspaceIndex>();
32+
33+function toPosixPath(input: string): string {
34+ return input.split(path.sep).join("/");
35+}
36+
37+function parentPathOf(input: string): string | undefined {
38+ const separatorIndex = input.lastIndexOf("/");
39+ if (separatorIndex === -1) {
40+ return undefined;
41+ }
42+ return input.slice(0, separatorIndex);
43+}
44+
45+function basenameOf(input: string): string {
46+ const separatorIndex = input.lastIndexOf("/");
47+ if (separatorIndex === -1) {
48+ return input;
49+ }
50+ return input.slice(separatorIndex + 1);
51+}
52+
53+function normalizeQuery(input: string): string {
54+ return input.trim().replace(/^[@./]+/, "").toLowerCase();
55+}
56+
57+function scoreEntry(entry: ProjectEntry, query: string): number {
58+ if (!query) {
59+ return entry.kind === "directory" ? 0 : 1;
60+ }
61+
62+ const normalizedPath = entry.path.toLowerCase();
63+ const normalizedName = basenameOf(normalizedPath);
64+
65+ if (normalizedName === query) return 0;
66+ if (normalizedPath === query) return 1;
67+ if (normalizedName.startsWith(query)) return 2;
68+ if (normalizedPath.startsWith(query)) return 3;
69+ if (normalizedPath.includes(`/${query}`)) return 4;
70+ return 5;
71+}
72+
73+async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
74+ let pendingDirectories: string[] = [""];
75+ const entries: ProjectEntry[] = [];
76+ let truncated = false;
77+
78+ while (pendingDirectories.length > 0 && !truncated) {
79+ const currentDirectories = pendingDirectories;
80+ pendingDirectories = [];
81+ const directoryEntries = await Promise.all(
82+ currentDirectories.map(async (relativeDir) => {
83+ const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd;
84+ try {
85+ const dirents = await fs.readdir(absoluteDir, { withFileTypes: true });
86+ return { relativeDir, dirents };
87+ } catch (error) {
88+ if (!relativeDir) {
89+ throw new Error(
90+ `Unable to scan workspace entries at '${cwd}': ${error instanceof Error ? error.message : "unknown error"}`,
91+ { cause: error },
92+ );
93+ }
94+ return { relativeDir, dirents: null };
95+ }
96+ }),
97+ );
98+
99+ for (const directoryEntry of directoryEntries) {
100+ const { relativeDir, dirents } = directoryEntry;
101+ if (!dirents) {
102+ continue;
103+ }
104+
105+ dirents.sort((left, right) => left.name.localeCompare(right.name));
106+ for (const dirent of dirents) {
107+ if (!dirent.name || dirent.name === "." || dirent.name === "..") {
108+ continue;
109+ }
110+ if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) {
111+ continue;
112+ }
113+ if (!dirent.isDirectory() && !dirent.isFile()) {
114+ continue;
115+ }
116+
117+ const relativePath = toPosixPath(
118+ relativeDir ? path.join(relativeDir, dirent.name) : dirent.name,
119+ );
120+ const entry: ProjectEntry = {
121+ path: relativePath,
122+ kind: dirent.isDirectory() ? "directory" : "file",
123+ parentPath: parentPathOf(relativePath),
124+ };
125+ entries.push(entry);
126+
127+ if (dirent.isDirectory()) {
128+ pendingDirectories.push(relativePath);
129+ }
130+
131+ if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) {
132+ truncated = true;
133+ break;
134+ }
135+ }
136+
137+ if (truncated) {
138+ break;
139+ }
140+ }
141+ }
142+
143+ return {
144+ scannedAt: Date.now(),
145+ entries,
146+ truncated,
147+ };
148+}
149+
150+async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
151+ const cached = workspaceIndexCache.get(cwd);
152+ if (cached && Date.now() - cached.scannedAt < WORKSPACE_CACHE_TTL_MS) {
153+ return cached;
154+ }
155+
156+ const next = await buildWorkspaceIndex(cwd);
157+ workspaceIndexCache.set(cwd, next);
158+ while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) {
159+ const oldestKey = workspaceIndexCache.keys().next().value;
160+ if (!oldestKey) break;
161+ workspaceIndexCache.delete(oldestKey);
162+ }
163+ return next;
164+}
165+
166+export async function searchWorkspaceEntries(
167+ input: ProjectSearchEntriesInput,
168+): Promise<ProjectSearchEntriesResult> {
169+ const parsed = projectSearchEntriesInputSchema.parse(input);
170+ const index = await getWorkspaceIndex(parsed.cwd);
171+ const normalizedQuery = normalizeQuery(parsed.query);
172+ const candidates = normalizedQuery
173+ ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery))
174+ : index.entries;
175+
176+ const ranked = candidates.toSorted((left, right) => {
177+ const scoreDelta = scoreEntry(left, normalizedQuery) - scoreEntry(right, normalizedQuery);
178+ if (scoreDelta !== 0) return scoreDelta;
179+ return left.path.localeCompare(right.path);
180+ });
181+
182+ return {
183+ entries: ranked.slice(0, parsed.limit),
184+ truncated: index.truncated || ranked.length > parsed.limit,
185+ };
186+}
apps/server/src/wsServer.test.ts+32−0
@@ -766,6 +766,38 @@ describe("WebSocket Server", () => { · 765 unmodified lines
766766 expect(afterRemove.result).toEqual([]);
767767 });
768768
769+ it("supports projects.searchEntries", async () => {
770+ const workspace = makeTempDir("t3code-ws-workspace-entries-");
771+ fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true });
772+ fs.writeFileSync(path.join(workspace, "src", "components", "Composer.tsx"), "export {};", "utf8");
773+ fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8");
774+ fs.mkdirSync(path.join(workspace, ".git"), { recursive: true });
775+ fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
776+
777+ server = createTestServer({ cwd: "/test" });
778+ await server.start();
779+ const addr = server.httpServer.address();
780+ const port = typeof addr === "object" && addr !== null ? addr.port : 0;
781+
782+ const ws = await connectWs(port);
783+ connections.push(ws);
784+ await waitForMessage(ws);
785+
786+ const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
787+ cwd: workspace,
788+ query: "comp",
789+ limit: 10,
790+ });
791+ expect(response.error).toBeUndefined();
792+ expect(response.result).toEqual({
793+ entries: expect.arrayContaining([
794+ expect.objectContaining({ path: "src/components", kind: "directory" }),
795+ expect.objectContaining({ path: "src/components/Composer.tsx", kind: "file" }),
796+ ]),
797+ truncated: false,
798+ });
799+ });
800+
769801 it("supports git methods over websocket", async () => {
770802 const repoCwd = makeTempDir("t3code-ws-git-project-");
771803
apps/server/src/wsServer.ts+4−0
@@ -32,6 +32,7 @@ import { · 31 unmodified lines
3232 } from "./git";
3333 import { TerminalManager } from "./terminalManager";
3434 import { loadResolvedKeybindingsConfig } from "./keybindings";
35+import { searchWorkspaceEntries } from "./workspaceEntries";
3536
3637 const MIME_TYPES: Record<string, string> = {
3738 ".html": "text/html; charset=utf-8",
@@ -319,6 +320,9 @@ export function createServer(options: ServerOptions) { · 281 unmodified lines
319320 projectRegistry.remove(request.params as never);
320321 return undefined;
321322
323+ case WS_METHODS.projectsSearchEntries:
324+ return searchWorkspaceEntries(request.params as never);
325+
322326 case WS_METHODS.shellOpenInEditor: {
323327 const params = request.params as {
324328 cwd: string;
apps/web/src/components/ChatView.tsx+295−8
@@ -1,6 +1,7 @@
11 import {
22 EDITORS,
33 type EditorId,
4+ type ProjectEntry,
45 ModelSlug,
56 type NativeApi,
67 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
@@ -31,6 +32,12 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; · 24 unmodified lines
3132
3233 import { isElectron } from "../env";
3334 import { buildBootstrapInput } from "../historyBootstrap";
35+import {
36+ buildPromptInput,
37+ buildUserVisiblePrompt,
38+ detectComposerTrigger,
39+ replaceTextRange,
40+} from "../composer-logic";
3441 import {
3542 DEFAULT_MODEL,
3643 DEFAULT_REASONING,
@@ -69,6 +76,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; · 32 unmodified lines
6976 import {
7077 ChevronDownIcon,
7178 CircleAlertIcon,
79+ FileIcon,
80+ FolderIcon,
7281 FolderClosedIcon,
7382 InfoIcon,
7483 LockIcon,
@@ -83,6 +92,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" · 8 unmodified lines
8392 import { CursorIcon, Icon } from "./Icons";
8493 import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
8594 import { Badge } from "./ui/badge";
95+import { Command, CommandItem, CommandList, CommandPanel } from "./ui/command";
8696
8797 function formatMessageMeta(createdAt: string, duration: string | null): string {
8898 if (!duration) return formatTimestamp(createdAt);
@@ -103,6 +113,12 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { · 14 unmodified lines
103113 return "text-muted-foreground/40";
104114 }
105115
116+function basenameOfPath(pathValue: string): string {
117+ const slashIndex = pathValue.lastIndexOf("/");
118+ if (slashIndex === -1) return pathValue;
119+ return pathValue.slice(slashIndex + 1);
120+}
121+
106122 type SessionContinuityState = "resumed" | "new" | "fallback_new";
107123
108124 interface EnsuredSessionInfo {
@@ -121,6 +137,29 @@ interface ExpandedImagePreview { · 12 unmodified lines
121137 name: string;
122138 }
123139
140+type ComposerCommandItem =
141+ | {
142+ id: string;
143+ type: "path";
144+ path: string;
145+ pathKind: ProjectEntry["kind"];
146+ label: string;
147+ description: string;
148+ }
149+ | {
150+ id: string;
151+ type: "slash-command";
152+ label: string;
153+ description: string;
154+ }
155+ | {
156+ id: string;
157+ type: "model";
158+ model: ModelSlug;
159+ label: string;
160+ description: string;
161+ };
162+
124163 function readFileAsDataUrl(file: File): Promise<string> {
125164 return new Promise((resolve, reject) => {
126165 const reader = new FileReader();
@@ -146,6 +185,7 @@ export default function ChatView() { · 19 unmodified lines
146185 gitCreateWorktreeMutationOptions({ api, queryClient }),
147186 );
148187 const [prompt, setPrompt] = useState("");
188+ const [taggedPaths, setTaggedPaths] = useState<string[]>([]);
149189 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
150190 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
151191 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
@@ -158,6 +198,8 @@ export default function ChatView() { · 6 unmodified lines
158198 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
159199 const [nowTick, setNowTick] = useState(() => Date.now());
160200 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
201+ const [composerCursor, setComposerCursor] = useState(0);
202+ const [composerMenuIndex, setComposerMenuIndex] = useState(0);
161203 const messagesScrollRef = useRef<HTMLDivElement>(null);
162204 const messagesEndRef = useRef<HTMLDivElement>(null);
163205 const shouldAutoScrollRef = useRef(true);
@@ -268,11 +310,79 @@ export default function ChatView() { · 104 unmodified lines
268310 sandboxMode: "workspace-write",
269311 } as const);
270312 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
313+ const composerTrigger = useMemo(
314+ () => detectComposerTrigger(prompt, composerCursor),
315+ [prompt, composerCursor],
316+ );
271317 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));
272318 const keybindingsQuery = useQuery({
273319 ...serverConfigQueryOptions(api),
274320 select: (config) => config.keybindings,
275321 });
322+ const workspaceEntriesQuery = useQuery({
323+ queryKey: [
324+ "composer-workspace-entries",
325+ gitCwd,
326+ composerTrigger?.kind === "path" ? composerTrigger.query : "",
327+ ],
328+ enabled: Boolean(api && gitCwd && composerTrigger?.kind === "path"),
329+ staleTime: 15_000,
330+ queryFn: async () => {
331+ if (!api || !gitCwd || composerTrigger?.kind !== "path") {
332+ return { entries: [], truncated: false };
333+ }
334+ return api.projects.searchEntries({
335+ cwd: gitCwd,
336+ query: composerTrigger.query,
337+ limit: 80,
338+ });
339+ },
340+ });
341+ const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
342+ if (!composerTrigger) return [];
343+ if (composerTrigger.kind === "path") {
344+ return (workspaceEntriesQuery.data?.entries ?? [])
345+ .filter((entry) => !taggedPaths.includes(entry.path))
346+ .map((entry) => ({
347+ id: `path:${entry.kind}:${entry.path}`,
348+ type: "path",
349+ path: entry.path,
350+ pathKind: entry.kind,
351+ label: basenameOfPath(entry.path),
352+ description: entry.parentPath ?? (entry.kind === "directory" ? "Folder" : "File"),
353+ }));
354+ }
355+
356+ if (composerTrigger.kind === "slash-command") {
357+ if (!"model".includes(composerTrigger.query.toLowerCase())) {
358+ return [];
359+ }
360+ return [
361+ {
362+ id: "slash:model",
363+ type: "slash-command",
364+ label: "/model",
365+ description: "Switch response model for this thread",
366+ },
367+ ];
368+ }
369+
370+ return MODEL_OPTIONS.filter(({ slug, name }) => {
371+ const query = composerTrigger.query.trim().toLowerCase();
372+ if (!query) return true;
373+ return slug.toLowerCase().includes(query) || name.toLowerCase().includes(query);
374+ }).map(({ slug, name }) => ({
375+ id: `model:${slug}`,
376+ type: "model",
377+ model: slug,
378+ label: name,
379+ description: slug,
380+ }));
381+ }, [composerTrigger, taggedPaths, workspaceEntriesQuery.data?.entries]);
382+ const composerMenuOpen = Boolean(composerTrigger);
383+ const activeComposerMenuItem =
384+ composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ??
385+ null;
276386 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
277387 // Default true while loading to avoid toolbar flicker.
278388 const isGitRepo = branchesQuery.data?.isRepo ?? true;
@@ -438,6 +548,10 @@ export default function ChatView() { · 159 unmodified lines
438548 setExpandedWorkGroups({});
439549 }, [activeThread?.id]);
440550
551+ useEffect(() => {
552+ setComposerMenuIndex(0);
553+ }, [composerTrigger?.kind, composerTrigger?.query, composerMenuItems.length]);
554+
441555 useEffect(() => {
442556 if (!activeThread?.id || activeThread.terminalOpen) return;
443557 const frame = window.requestAnimationFrame(() => {
@@ -457,6 +571,9 @@ export default function ChatView() { · 13 unmodified lines
457571 revokePreviewUrls(existing);
458572 return [];
459573 });
574+ setTaggedPaths([]);
575+ setComposerCursor(0);
576+ setComposerMenuIndex(0);
460577 dragDepthRef.current = 0;
461578 setIsDragOverComposer(false);
462579 setExpandedImage(null);
@@ -773,7 +890,10 @@ export default function ChatView() { · 310 unmodified lines
773890 e.preventDefault();
774891 if (!api || !activeThread || isSending || isConnecting) return;
775892 const trimmed = prompt.trim();
776− if (!trimmed && composerImages.length === 0) return;
893+ const taggedPathsSnapshot = [...taggedPaths];
894+ const promptForModel = buildPromptInput(trimmed, taggedPathsSnapshot);
895+ const userVisiblePrompt = buildUserVisiblePrompt(trimmed, taggedPathsSnapshot);
896+ if (!promptForModel && composerImages.length === 0) return;
777897 if (!activeProject) return;
778898 const composerImagesSnapshot = [...composerImages];
779899
@@ -813,6 +933,7 @@ export default function ChatView() { · 33 unmodified lines
813933 if (activeThread.messages.length === 0) {
814934 const titleSeed =
815935 trimmed ||
936+ (taggedPathsSnapshot.length > 0 ? `Paths: ${taggedPathsSnapshot[0]}` : "") ||
816937 (composerImagesSnapshot.length > 0
817938 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
818939 : "New thread");
@@ -837,12 +958,15 @@ export default function ChatView() { · 18 unmodified lines
837958 type: "PUSH_USER_MESSAGE",
838959 threadId: activeThread.id,
839960 id: crypto.randomUUID(),
840− text: trimmed,
961+ text: userVisiblePrompt,
841962 ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}),
842963 });
843964 const previousMessages = activeThread.messages;
844965 setPrompt("");
845966 setComposerImages([]);
967+ setTaggedPaths([]);
968+ setComposerCursor(0);
969+ setComposerMenuIndex(0);
846970
847971 const sessionInfo = await ensureSession(sessionCwd);
848972 if (!sessionInfo) return;
@@ -863,14 +987,14 @@ export default function ChatView() { · 14 unmodified lines
863987 const shouldBootstrap =
864988 previousMessages.length > 0 &&
865989 (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new");
866− const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT;
990+ const latestPromptForBootstrap = promptForModel || IMAGE_ONLY_BOOTSTRAP_PROMPT;
867991 const input = shouldBootstrap
868992 ? buildBootstrapInput(
869993 previousMessages,
870994 latestPromptForBootstrap,
871995 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
872996 ).text
873− : trimmed || undefined;
997+ : promptForModel || undefined;
874998 await api.providers.sendTurn({
875999 sessionId: sessionInfo.sessionId,
8761000 ...(input ? { input } : {}),
@@ -949,7 +1073,74 @@ export default function ChatView() { · 72 unmodified lines
9491073 [scheduleComposerFocus],
9501074 );
9511075
1076+ const applyPromptReplacement = useCallback(
1077+ (rangeStart: number, rangeEnd: number, replacement: string) => {
1078+ setPrompt((existing) => {
1079+ const next = replaceTextRange(existing, rangeStart, rangeEnd, replacement);
1080+ window.requestAnimationFrame(() => {
1081+ const textarea = textareaRef.current;
1082+ if (!textarea) return;
1083+ textarea.focus();
1084+ textarea.setSelectionRange(next.cursor, next.cursor);
1085+ setComposerCursor(next.cursor);
1086+ });
1087+ return next.text;
1088+ });
1089+ },
1090+ [],
1091+ );
1092+
1093+ const onSelectComposerItem = useCallback(
1094+ (item: ComposerCommandItem) => {
1095+ if (!composerTrigger) return;
1096+ if (item.type === "path") {
1097+ setTaggedPaths((existing) =>
1098+ existing.includes(item.path) ? existing : [...existing, item.path],
1099+ );
1100+ applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, "");
1101+ return;
1102+ }
1103+ if (item.type === "slash-command") {
1104+ applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, "/model ");
1105+ return;
1106+ }
1107+ onModelSelect(item.model);
1108+ applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, "");
1109+ },
1110+ [applyPromptReplacement, composerTrigger, onModelSelect],
1111+ );
1112+
1113+ const onRemoveTaggedPath = useCallback((pathToRemove: string) => {
1114+ setTaggedPaths((existing) => existing.filter((entry) => entry !== pathToRemove));
1115+ }, []);
1116+
1117+ const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {
1118+ setPrompt(nextPrompt);
1119+ setComposerCursor(nextCursor);
1120+ }, []);
1121+
9521122 const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
1123+ if (composerMenuOpen && composerMenuItems.length > 0) {
1124+ if (e.key === "ArrowDown") {
1125+ e.preventDefault();
1126+ setComposerMenuIndex((existing) => Math.min(existing + 1, composerMenuItems.length - 1));
1127+ return;
1128+ }
1129+ if (e.key === "ArrowUp") {
1130+ e.preventDefault();
1131+ setComposerMenuIndex((existing) => Math.max(existing - 1, 0));
1132+ return;
1133+ }
1134+ if (e.key === "Tab" || e.key === "Enter") {
1135+ const selectedItem = activeComposerMenuItem ?? composerMenuItems[0];
1136+ if (selectedItem) {
1137+ e.preventDefault();
1138+ onSelectComposerItem(selectedItem);
1139+ return;
1140+ }
1141+ }
1142+ }
1143+
9531144 if (e.key === "Enter" && !e.shiftKey) {
9541145 e.preventDefault();
9551146 void onSend(e as unknown as FormEvent);
@@ -1045,7 +1236,81 @@ export default function ChatView() { · 89 unmodified lines
10451236 onDrop={onComposerDrop}
10461237 >
10471238 {/* Textarea area */}
1048− <div className="px-4 pt-4 pb-2">
1239+ <div className="relative px-4 pt-4 pb-2">
1240+ {composerMenuOpen && (
1241+ <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
1242+ <Command>
1243+ <CommandPanel className="rounded-xl border border-border/80 bg-popover/96 backdrop-blur-xs">
1244+ <CommandList className="max-h-64">
1245+ {composerMenuItems.map((item, index) => (
1246+ <CommandItem
1247+ key={item.id}
1248+ value={item.id}
1249+ className={cn(
1250+ "gap-2",
1251+ index === composerMenuIndex && "bg-accent text-accent-foreground",
1252+ )}
1253+ onMouseEnter={() => setComposerMenuIndex(index)}
1254+ onMouseDown={(event) => {
1255+ event.preventDefault();
1256+ onSelectComposerItem(item);
1257+ }}
1258+ >
1259+ {item.type === "path" ? (
1260+ item.pathKind === "directory" ? (
1261+ <FolderIcon className="size-4 text-muted-foreground/80" />
1262+ ) : (
1263+ <FileIcon className="size-4 text-muted-foreground/80" />
1264+ )
1265+ ) : null}
1266+ {item.type === "slash-command" ? (
1267+ <CursorIcon className="size-4 text-muted-foreground/80" />
1268+ ) : null}
1269+ {item.type === "model" ? (
1270+ <Badge variant="outline" className="px-1.5 py-0 text-[10px]">
1271+ model
1272+ </Badge>
1273+ ) : null}
1274+ <span className="truncate">{item.label}</span>
1275+ <span className="truncate text-muted-foreground/70 text-xs">
1276+ {item.description}
1277+ </span>
1278+ </CommandItem>
1279+ ))}
1280+ </CommandList>
1281+ {composerMenuItems.length === 0 && (
1282+ <p className="px-3 py-2 text-muted-foreground/70 text-xs">
1283+ {workspaceEntriesQuery.isLoading
1284+ ? "Searching workspace files..."
1285+ : composerTrigger?.kind === "path"
1286+ ? "No matching files or folders."
1287+ : "No matching command."}
1288+ </p>
1289+ )}
1290+ </CommandPanel>
1291+ </Command>
1292+ </div>
1293+ )}
1294+
1295+ {taggedPaths.length > 0 && (
1296+ <div className="mb-3 flex flex-wrap gap-1.5">
1297+ {taggedPaths.map((taggedPath) => (
1298+ <button
1299+ key={taggedPath}
1300+ type="button"
1301+ className="inline-flex items-center gap-1 rounded-md border border-border/80 bg-secondary/65 px-2 py-1 text-[11px] text-foreground/85 transition-colors hover:bg-secondary"
1302+ onClick={() => onRemoveTaggedPath(taggedPath)}
1303+ aria-label={`Remove ${taggedPath}`}
1304+ title={taggedPath}
1305+ >
1306+ <span className="text-muted-foreground/70">@</span>
1307+ <span className="max-w-64 truncate">{taggedPath}</span>
1308+ <XIcon className="size-3 text-muted-foreground/70" />
1309+ </button>
1310+ ))}
1311+ </div>
1312+ )}
1313+
10491314 {composerImages.length > 0 && (
10501315 <div className="mb-3 flex flex-wrap gap-2">
10511316 {composerImages.map((image) => (
@@ -1085,13 +1350,33 @@ export default function ChatView() { · 33 unmodified lines
10851350 className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none"
10861351 rows={2}
10871352 value={prompt}
1088− onChange={(e) => setPrompt(e.target.value)}
1353+ onChange={(event) =>
1354+ onPromptChange(
1355+ event.target.value,
1356+ event.target.selectionStart ?? event.target.value.length,
1357+ )
1358+ }
10891359 onKeyDown={onKeyDown}
1360+ onKeyUp={(event) =>
1361+ setComposerCursor(
1362+ event.currentTarget.selectionStart ?? event.currentTarget.value.length,
1363+ )
1364+ }
1365+ onClick={(event) =>
1366+ setComposerCursor(
1367+ event.currentTarget.selectionStart ?? event.currentTarget.value.length,
1368+ )
1369+ }
1370+ onSelect={(event) =>
1371+ setComposerCursor(
1372+ event.currentTarget.selectionStart ?? event.currentTarget.value.length,
1373+ )
1374+ }
10901375 onPaste={onComposerPaste}
10911376 placeholder={
10921377 phase === "disconnected"
10931378 ? "Ask for follow-up changes or attach images"
1094− : "Ask anything, or attach images..."
1379+ : "Ask anything, @tag files/folders, or use /model"
10951380 }
10961381 disabled={isSending || isConnecting}
10971382 />
@@ -1159,7 +1444,9 @@ export default function ChatView() { · 61 unmodified lines
11591444 type="submit"
11601445 className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100"
11611446 disabled={
1162− isSending || isConnecting || (!prompt.trim() && composerImages.length === 0)
1447+ isSending ||
1448+ isConnecting ||
1449+ (!prompt.trim() && composerImages.length === 0 && taggedPaths.length === 0)
11631450 }
11641451 aria-label={
11651452 isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"
apps/web/src/composer-logic.test.ts+72−0
@@ -0,0 +1,72 @@
1+import { describe, expect, it } from "vitest";
2+
3+import {
4+ buildPromptInput,
5+ buildUserVisiblePrompt,
6+ detectComposerTrigger,
7+ replaceTextRange,
8+} from "./composer-logic";
9+
10+describe("detectComposerTrigger", () => {
11+ it("detects @path trigger at cursor", () => {
12+ const text = "Please check @src/com";
13+ const trigger = detectComposerTrigger(text, text.length);
14+
15+ expect(trigger).toEqual({
16+ kind: "path",
17+ query: "src/com",
18+ rangeStart: "Please check ".length,
19+ rangeEnd: text.length,
20+ });
21+ });
22+
23+ it("detects slash command token while typing command name", () => {
24+ const text = "/mo";
25+ const trigger = detectComposerTrigger(text, text.length);
26+
27+ expect(trigger).toEqual({
28+ kind: "slash-command",
29+ query: "mo",
30+ rangeStart: 0,
31+ rangeEnd: text.length,
32+ });
33+ });
34+
35+ it("detects slash model query after /model", () => {
36+ const text = "/model spark";
37+ const trigger = detectComposerTrigger(text, text.length);
38+
39+ expect(trigger).toEqual({
40+ kind: "slash-model",
41+ query: "spark",
42+ rangeStart: 0,
43+ rangeEnd: text.length,
44+ });
45+ });
46+});
47+
48+describe("replaceTextRange", () => {
49+ it("replaces a text range and returns new cursor", () => {
50+ const replaced = replaceTextRange("hello @src", 6, 10, "");
51+ expect(replaced).toEqual({
52+ text: "hello ",
53+ cursor: 6,
54+ });
55+ });
56+});
57+
58+describe("tagged path prompt helpers", () => {
59+ it("builds model input with tagged paths", () => {
60+ const result = buildPromptInput("Fix this", ["src/app.tsx", "src/app.tsx", "README.md"]);
61+ expect(result).toContain("Fix this");
62+ expect(result).toContain("Referenced paths:");
63+ expect(result).toContain("- src/app.tsx");
64+ expect(result).toContain("- README.md");
65+ expect(result).not.toContain("- src/app.tsx\n- src/app.tsx");
66+ });
67+
68+ it("builds user-visible prompt from tags only", () => {
69+ const result = buildUserVisiblePrompt(" ", ["src/app.tsx", "README.md"]);
70+ expect(result).toBe("@src/app.tsx @README.md");
71+ });
72+});
apps/web/src/composer-logic.ts+128−0
@@ -0,0 +1,128 @@
1+export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";
2+
3+export interface ComposerTrigger {
4+ kind: ComposerTriggerKind;
5+ query: string;
6+ rangeStart: number;
7+ rangeEnd: number;
8+}
9+
10+function clampCursor(text: string, cursor: number): number {
11+ if (!Number.isFinite(cursor)) return text.length;
12+ return Math.max(0, Math.min(text.length, Math.floor(cursor)));
13+}
14+
15+function isWhitespace(char: string): boolean {
16+ return char === " " || char === "\n" || char === "\t" || char === "\r";
17+}
18+
19+function tokenStartForCursor(text: string, cursor: number): number {
20+ let index = cursor - 1;
21+ while (index >= 0 && !isWhitespace(text[index] ?? "")) {
22+ index -= 1;
23+ }
24+ return index + 1;
25+}
26+
27+function dedupePaths(paths: string[]): string[] {
28+ const unique = new Set<string>();
29+ for (const rawPath of paths) {
30+ const normalized = rawPath.trim();
31+ if (!normalized) continue;
32+ unique.add(normalized);
33+ }
34+ return [...unique];
35+}
36+
37+export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {
38+ const cursor = clampCursor(text, cursorInput);
39+ const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;
40+ const linePrefix = text.slice(lineStart, cursor);
41+
42+ if (linePrefix.startsWith("/")) {
43+ const commandMatch = /^\/(\S*)$/.exec(linePrefix);
44+ if (commandMatch) {
45+ const commandQuery = commandMatch[1] ?? "";
46+ if (commandQuery.toLowerCase() === "model") {
47+ return {
48+ kind: "slash-model",
49+ query: "",
50+ rangeStart: lineStart,
51+ rangeEnd: cursor,
52+ };
53+ }
54+ if ("model".startsWith(commandQuery.toLowerCase())) {
55+ return {
56+ kind: "slash-command",
57+ query: commandQuery,
58+ rangeStart: lineStart,
59+ rangeEnd: cursor,
60+ };
61+ }
62+ return null;
63+ }
64+
65+ const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix);
66+ if (modelMatch) {
67+ return {
68+ kind: "slash-model",
69+ query: (modelMatch[1] ?? "").trim(),
70+ rangeStart: lineStart,
71+ rangeEnd: cursor,
72+ };
73+ }
74+ }
75+
76+ const tokenStart = tokenStartForCursor(text, cursor);
77+ const token = text.slice(tokenStart, cursor);
78+ if (!token.startsWith("@")) {
79+ return null;
80+ }
81+
82+ return {
83+ kind: "path",
84+ query: token.slice(1),
85+ rangeStart: tokenStart,
86+ rangeEnd: cursor,
87+ };
88+}
89+
90+export function replaceTextRange(
91+ text: string,
92+ rangeStart: number,
93+ rangeEnd: number,
94+ replacement: string,
95+): { text: string; cursor: number } {
96+ const safeStart = Math.max(0, Math.min(text.length, rangeStart));
97+ const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd));
98+ const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`;
99+ return { text: nextText, cursor: safeStart + replacement.length };
100+}
101+
102+export function buildPromptInput(text: string, taggedPaths: string[]): string {
103+ const trimmedPrompt = text.trim();
104+ const normalizedPaths = dedupePaths(taggedPaths);
105+ if (normalizedPaths.length === 0) {
106+ return trimmedPrompt;
107+ }
108+
109+ const tagBlock = `Referenced paths:\n${normalizedPaths.map((entry) => `- ${entry}`).join("\n")}`;
110+ if (!trimmedPrompt) {
111+ return tagBlock;
112+ }
113+ return `${trimmedPrompt}\n\n${tagBlock}`;
114+}
115+
116+export function buildUserVisiblePrompt(text: string, taggedPaths: string[]): string {
117+ const trimmedPrompt = text.trim();
118+ const normalizedPaths = dedupePaths(taggedPaths);
119+ if (normalizedPaths.length === 0) {
120+ return trimmedPrompt;
121+ }
122+
123+ const tagsSummary = normalizedPaths.map((entry) => `@${entry}`).join(" ");
124+ if (!trimmedPrompt) {
125+ return tagsSummary;
126+ }
127+ return `${trimmedPrompt}\n\n${tagsSummary}`;
128+}
apps/web/src/wsNativeApi.ts+1−0
@@ -98,6 +98,7 @@ export function createWsNativeApi(): NativeApi { · 97 unmodified lines
9898 list: () => transport.request(WS_METHODS.projectsList),
9999 add: (input) => transport.request(WS_METHODS.projectsAdd, input),
100100 remove: (input) => transport.request(WS_METHODS.projectsRemove, input),
101+ searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),
101102 },
102103 shell: {
103104 openInEditor: (cwd, editor) =>
packages/contracts/src/ipc.ts+3−0
@@ -29,6 +29,8 @@ import type { · 28 unmodified lines
2929 ProjectAddInput,
3030 ProjectAddResult,
3131 ProjectListResult,
32+ ProjectSearchEntriesInput,
33+ ProjectSearchEntriesResult,
3234 ProjectRemoveInput,
3335 } from "./project";
3436 import type { ServerConfig } from "./server";
@@ -90,6 +92,7 @@ export interface NativeApi { · 55 unmodified lines
9092 list: () => Promise<ProjectListResult>;
9193 add: (input: ProjectAddInput) => Promise<ProjectAddResult>;
9294 remove: (input: ProjectRemoveInput) => Promise<void>;
95+ searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
9396 };
9497 shell: {
9598 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
packages/contracts/src/project.test.ts+36−0
@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; · 2 unmodified lines
33 import {
44 projectAddInputSchema,
55 projectAddResultSchema,
6+ projectSearchEntriesInputSchema,
7+ projectSearchEntriesResultSchema,
68 projectListResultSchema,
79 projectRemoveInputSchema,
810 } from "./project";
@@ -46,4 +48,38 @@ describe("project contracts", () => { · 37 unmodified lines
4648 const parsed = projectRemoveInputSchema.parse({ id: "project-1" });
4749 expect(parsed.id).toBe("project-1");
4850 });
51+
52+ it("parses workspace entry search input with defaults", () => {
53+ const parsed = projectSearchEntriesInputSchema.parse({
54+ cwd: " /tmp/project ",
55+ query: " src ",
56+ });
57+
58+ expect(parsed).toEqual({
59+ cwd: "/tmp/project",
60+ query: "src",
61+ limit: 80,
62+ });
63+ });
64+
65+ it("parses workspace entry search result", () => {
66+ const parsed = projectSearchEntriesResultSchema.parse({
67+ entries: [
68+ {
69+ path: "src/components",
70+ kind: "directory",
71+ parentPath: "src",
72+ },
73+ {
74+ path: "src/components/Button.tsx",
75+ kind: "file",
76+ parentPath: "src/components",
77+ },
78+ ],
79+ truncated: false,
80+ });
81+
82+ expect(parsed.entries).toHaveLength(2);
83+ expect(parsed.entries[0]?.kind).toBe("directory");
84+ });
4985 });
packages/contracts/src/project.ts+25−0
@@ -23,8 +23,33 @@ export const projectRemoveInputSchema = z.object({ · 22 unmodified lines
2323 id: z.string().min(1),
2424 });
2525
26+export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
27+
28+export const projectSearchEntriesInputSchema = z.object({
29+ cwd: z.string().trim().min(1),
30+ query: z.string().trim().max(256).default(""),
31+ limit: z.number().int().min(1).max(PROJECT_SEARCH_ENTRIES_MAX_LIMIT).default(80),
32+});
33+
34+export const projectEntryKindSchema = z.enum(["file", "directory"]);
35+
36+export const projectEntrySchema = z.object({
37+ path: z.string().min(1),
38+ kind: projectEntryKindSchema,
39+ parentPath: z.string().optional(),
40+});
41+
42+export const projectSearchEntriesResultSchema = z.object({
43+ entries: z.array(projectEntrySchema),
44+ truncated: z.boolean(),
45+});
46+
2647 export type ProjectRecord = z.infer<typeof projectRecordSchema>;
2748 export type ProjectListResult = z.infer<typeof projectListResultSchema>;
2849 export type ProjectAddInput = z.input<typeof projectAddInputSchema>;
2950 export type ProjectAddResult = z.infer<typeof projectAddResultSchema>;
3051 export type ProjectRemoveInput = z.input<typeof projectRemoveInputSchema>;
52+export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInputSchema>;
53+export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>;
54+export type ProjectEntry = z.infer<typeof projectEntrySchema>;
55+export type ProjectSearchEntriesResult = z.infer<typeof projectSearchEntriesResultSchema>;
packages/contracts/src/ws.ts+1−0
@@ -15,6 +15,7 @@ export const WS_METHODS = { · 14 unmodified lines
1515 projectsList: "projects.list",
1616 projectsAdd: "projects.add",
1717 projectsRemove: "projects.remove",
18+ projectsSearchEntries: "projects.searchEntries",
1819
1920 // Shell methods
2021 shellOpenInEditor: "shell.openInEditor",