authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-17 09:45:48-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-17 19:47:09-08:00
loge8556bcff95f4bdd74d4a7334626056f1e26a856
tree987218d32eaf5c55da90efb65ed80c8bc2e07e46
parenta1532d5b2de71e4a2c3d90d9653da02fa931d8d6

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

12 files changed, 845 insertions(+), 8 deletions(-)

apps/server/src/workspaceEntries.test.ts created+62
......@@ -0,0 +1,62 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import { searchWorkspaceEntries } from "./workspaceEntries";
8
9const tempDirs: string[] = [];
10
11function makeTempDir(prefix: string): string {
12 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
13 tempDirs.push(dir);
14 return dir;
15}
16
17function 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
23describe("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 created+186
......@@ -0,0 +1,186 @@
1import fs from "node:fs/promises";
2import path from "node:path";
3
4import {
5 projectSearchEntriesInputSchema,
6 type ProjectEntry,
7 type ProjectSearchEntriesInput,
8 type ProjectSearchEntriesResult,
9} from "@t3tools/contracts";
10
11const WORKSPACE_CACHE_TTL_MS = 15_000;
12const WORKSPACE_CACHE_MAX_KEYS = 4;
13const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
14const IGNORED_DIRECTORY_NAMES = new Set([
15 ".git",
16 "node_modules",
17 ".next",
18 ".turbo",
19 "dist",
20 "build",
21 "out",
22 ".cache",
23]);
24
25interface WorkspaceIndex {
26 scannedAt: number;
27 entries: ProjectEntry[];
28 truncated: boolean;
29}
30
31const workspaceIndexCache = new Map<string, WorkspaceIndex>();
32
33function toPosixPath(input: string): string {
34 return input.split(path.sep).join("/");
35}
36
37function 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
45function 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
53function normalizeQuery(input: string): string {
54 return input.trim().replace(/^[@./]+/, "").toLowerCase();
55}
56
57function 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
73async 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
150async 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
166export 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
......@@ -766,6 +766,38 @@ describe("WebSocket Server", () => {
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
......@@ -32,6 +32,7 @@ import {
3232} from "./git";
3333import { TerminalManager } from "./terminalManager";
3434import { loadResolvedKeybindingsConfig } from "./keybindings";
35import { searchWorkspaceEntries } from "./workspaceEntries";
3536
3637const MIME_TYPES: Record<string, string> = {
3738 ".html": "text/html; charset=utf-8",
......@@ -319,6 +320,9 @@ export function createServer(options: ServerOptions) {
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 @@
11import {
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";
3132
3233import { isElectron } from "../env";
3334import { buildBootstrapInput } from "../historyBootstrap";
35import {
36 buildPromptInput,
37 buildUserVisiblePrompt,
38 detectComposerTrigger,
39 replaceTextRange,
40} from "../composer-logic";
3441import {
3542 DEFAULT_MODEL,
3643 DEFAULT_REASONING,
......@@ -69,6 +76,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
6976import {
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"
8392import { CursorIcon, Icon } from "./Icons";
8493import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
8594import { Badge } from "./ui/badge";
95import { Command, CommandItem, CommandList, CommandPanel } from "./ui/command";
8696
8797function 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 {
103113 return "text-muted-foreground/40";
104114}
105115
116function basenameOfPath(pathValue: string): string {
117 const slashIndex = pathValue.lastIndexOf("/");
118 if (slashIndex === -1) return pathValue;
119 return pathValue.slice(slashIndex + 1);
120}
121
106122type SessionContinuityState = "resumed" | "new" | "fallback_new";
107123
108124interface EnsuredSessionInfo {
......@@ -121,6 +137,29 @@ interface ExpandedImagePreview {
121137 name: string;
122138}
123139
140type 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
124163function readFileAsDataUrl(file: File): Promise<string> {
125164 return new Promise((resolve, reject) => {
126165 const reader = new FileReader();
......@@ -146,6 +185,7 @@ export default function ChatView() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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 created+72
......@@ -0,0 +1,72 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 buildPromptInput,
5 buildUserVisiblePrompt,
6 detectComposerTrigger,
7 replaceTextRange,
8} from "./composer-logic";
9
10describe("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
48describe("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
58describe("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 created+128
......@@ -0,0 +1,128 @@
1export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";
2
3export interface ComposerTrigger {
4 kind: ComposerTriggerKind;
5 query: string;
6 rangeStart: number;
7 rangeEnd: number;
8}
9
10function 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
15function isWhitespace(char: string): boolean {
16 return char === " " || char === "\n" || char === "\t" || char === "\r";
17}
18
19function 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
27function 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
37export 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
90export 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
102export 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
116export 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
......@@ -98,6 +98,7 @@ export function createWsNativeApi(): NativeApi {
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
......@@ -29,6 +29,8 @@ import type {
2929 ProjectAddInput,
3030 ProjectAddResult,
3131 ProjectListResult,
32 ProjectSearchEntriesInput,
33 ProjectSearchEntriesResult,
3234 ProjectRemoveInput,
3335} from "./project";
3436import type { ServerConfig } from "./server";
......@@ -90,6 +92,7 @@ export interface NativeApi {
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
......@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";
33import {
44 projectAddInputSchema,
55 projectAddResultSchema,
6 projectSearchEntriesInputSchema,
7 projectSearchEntriesResultSchema,
68 projectListResultSchema,
79 projectRemoveInputSchema,
810} from "./project";
......@@ -46,4 +48,38 @@ describe("project contracts", () => {
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
......@@ -23,8 +23,33 @@ export const projectRemoveInputSchema = z.object({
2323 id: z.string().min(1),
2424});
2525
26export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
27
28export 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
34export const projectEntryKindSchema = z.enum(["file", "directory"]);
35
36export const projectEntrySchema = z.object({
37 path: z.string().min(1),
38 kind: projectEntryKindSchema,
39 parentPath: z.string().optional(),
40});
41
42export const projectSearchEntriesResultSchema = z.object({
43 entries: z.array(projectEntrySchema),
44 truncated: z.boolean(),
45});
46
2647export type ProjectRecord = z.infer<typeof projectRecordSchema>;
2748export type ProjectListResult = z.infer<typeof projectListResultSchema>;
2849export type ProjectAddInput = z.input<typeof projectAddInputSchema>;
2950export type ProjectAddResult = z.infer<typeof projectAddResultSchema>;
3051export type ProjectRemoveInput = z.input<typeof projectRemoveInputSchema>;
52export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInputSchema>;
53export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>;
54export type ProjectEntry = z.infer<typeof projectEntrySchema>;
55export type ProjectSearchEntriesResult = z.infer<typeof projectSearchEntriesResultSchema>;
packages/contracts/src/ws.ts+1
......@@ -15,6 +15,7 @@ export const WS_METHODS = {
1515 projectsList: "projects.list",
1616 projectsAdd: "projects.add",
1717 projectsRemove: "projects.remove",
18 projectsSearchEntries: "projects.searchEntries",
1819
1920 // Shell methods
2021 shellOpenInEditor: "shell.openInEditor",