diff --git a/apps/server/src/workspaceEntries.test.ts b/apps/server/src/workspaceEntries.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4cebfbf0f623bf9160f1cee692e0a737057efba --- /dev/null +++ b/apps/server/src/workspaceEntries.test.ts @@ -0,0 +1,62 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { searchWorkspaceEntries } from "./workspaceEntries"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function writeFile(cwd: string, relativePath: string, contents = ""): void { + const absolutePath = path.join(cwd, relativePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, contents, "utf8"); +} + +describe("searchWorkspaceEntries", () => { + afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns files and directories relative to cwd", async () => { + const cwd = makeTempDir("t3code-workspace-entries-"); + writeFile(cwd, "src/components/Composer.tsx"); + writeFile(cwd, "src/index.ts"); + writeFile(cwd, "README.md"); + writeFile(cwd, ".git/HEAD"); + writeFile(cwd, "node_modules/pkg/index.js"); + + const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); + const paths = result.entries.map((entry) => entry.path); + + expect(paths).toContain("src"); + expect(paths).toContain("src/components"); + expect(paths).toContain("src/components/Composer.tsx"); + expect(paths).toContain("README.md"); + expect(paths.some((entryPath) => entryPath.startsWith(".git"))).toBe(false); + expect(paths.some((entryPath) => entryPath.startsWith("node_modules"))).toBe(false); + expect(result.truncated).toBe(false); + }); + + it("filters and ranks entries by query", async () => { + const cwd = makeTempDir("t3code-workspace-query-"); + writeFile(cwd, "src/components/Composer.tsx"); + writeFile(cwd, "src/components/composePrompt.ts"); + writeFile(cwd, "docs/composition.md"); + + const result = await searchWorkspaceEntries({ cwd, query: "compo", limit: 5 }); + + expect(result.entries.length).toBeGreaterThan(0); + expect(result.entries.some((entry) => entry.path === "src/components")).toBe(true); + expect(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))).toBe(true); + }); +}); diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts new file mode 100644 index 0000000000000000000000000000000000000000..db584d0589341d7baac28e19dde18dc1b772e0d8 --- /dev/null +++ b/apps/server/src/workspaceEntries.ts @@ -0,0 +1,186 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { + projectSearchEntriesInputSchema, + type ProjectEntry, + type ProjectSearchEntriesInput, + type ProjectSearchEntriesResult, +} from "@t3tools/contracts"; + +const WORKSPACE_CACHE_TTL_MS = 15_000; +const WORKSPACE_CACHE_MAX_KEYS = 4; +const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; +const IGNORED_DIRECTORY_NAMES = new Set([ + ".git", + "node_modules", + ".next", + ".turbo", + "dist", + "build", + "out", + ".cache", +]); + +interface WorkspaceIndex { + scannedAt: number; + entries: ProjectEntry[]; + truncated: boolean; +} + +const workspaceIndexCache = new Map(); + +function toPosixPath(input: string): string { + return input.split(path.sep).join("/"); +} + +function parentPathOf(input: string): string | undefined { + const separatorIndex = input.lastIndexOf("/"); + if (separatorIndex === -1) { + return undefined; + } + return input.slice(0, separatorIndex); +} + +function basenameOf(input: string): string { + const separatorIndex = input.lastIndexOf("/"); + if (separatorIndex === -1) { + return input; + } + return input.slice(separatorIndex + 1); +} + +function normalizeQuery(input: string): string { + return input.trim().replace(/^[@./]+/, "").toLowerCase(); +} + +function scoreEntry(entry: ProjectEntry, query: string): number { + if (!query) { + return entry.kind === "directory" ? 0 : 1; + } + + const normalizedPath = entry.path.toLowerCase(); + const normalizedName = basenameOf(normalizedPath); + + if (normalizedName === query) return 0; + if (normalizedPath === query) return 1; + if (normalizedName.startsWith(query)) return 2; + if (normalizedPath.startsWith(query)) return 3; + if (normalizedPath.includes(`/${query}`)) return 4; + return 5; +} + +async function buildWorkspaceIndex(cwd: string): Promise { + let pendingDirectories: string[] = [""]; + const entries: ProjectEntry[] = []; + let truncated = false; + + while (pendingDirectories.length > 0 && !truncated) { + const currentDirectories = pendingDirectories; + pendingDirectories = []; + const directoryEntries = await Promise.all( + currentDirectories.map(async (relativeDir) => { + const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; + try { + const dirents = await fs.readdir(absoluteDir, { withFileTypes: true }); + return { relativeDir, dirents }; + } catch (error) { + if (!relativeDir) { + throw new Error( + `Unable to scan workspace entries at '${cwd}': ${error instanceof Error ? error.message : "unknown error"}`, + { cause: error }, + ); + } + return { relativeDir, dirents: null }; + } + }), + ); + + for (const directoryEntry of directoryEntries) { + const { relativeDir, dirents } = directoryEntry; + if (!dirents) { + continue; + } + + dirents.sort((left, right) => left.name.localeCompare(right.name)); + for (const dirent of dirents) { + if (!dirent.name || dirent.name === "." || dirent.name === "..") { + continue; + } + if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) { + continue; + } + if (!dirent.isDirectory() && !dirent.isFile()) { + continue; + } + + const relativePath = toPosixPath( + relativeDir ? path.join(relativeDir, dirent.name) : dirent.name, + ); + const entry: ProjectEntry = { + path: relativePath, + kind: dirent.isDirectory() ? "directory" : "file", + parentPath: parentPathOf(relativePath), + }; + entries.push(entry); + + if (dirent.isDirectory()) { + pendingDirectories.push(relativePath); + } + + if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) { + truncated = true; + break; + } + } + + if (truncated) { + break; + } + } + } + + return { + scannedAt: Date.now(), + entries, + truncated, + }; +} + +async function getWorkspaceIndex(cwd: string): Promise { + const cached = workspaceIndexCache.get(cwd); + if (cached && Date.now() - cached.scannedAt < WORKSPACE_CACHE_TTL_MS) { + return cached; + } + + const next = await buildWorkspaceIndex(cwd); + workspaceIndexCache.set(cwd, next); + while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) { + const oldestKey = workspaceIndexCache.keys().next().value; + if (!oldestKey) break; + workspaceIndexCache.delete(oldestKey); + } + return next; +} + +export async function searchWorkspaceEntries( + input: ProjectSearchEntriesInput, +): Promise { + const parsed = projectSearchEntriesInputSchema.parse(input); + const index = await getWorkspaceIndex(parsed.cwd); + const normalizedQuery = normalizeQuery(parsed.query); + const candidates = normalizedQuery + ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery)) + : index.entries; + + const ranked = candidates.toSorted((left, right) => { + const scoreDelta = scoreEntry(left, normalizedQuery) - scoreEntry(right, normalizedQuery); + if (scoreDelta !== 0) return scoreDelta; + return left.path.localeCompare(right.path); + }); + + return { + entries: ranked.slice(0, parsed.limit), + truncated: index.truncated || ranked.length > parsed.limit, + }; +} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 0c64f787730cffcafb58dea3558c62da7418a168..9c406357a302ab8d85693a6d4af5e05440339e8d 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -766,6 +766,38 @@ describe("WebSocket Server", () => { expect(afterRemove.result).toEqual([]); }); + it("supports projects.searchEntries", async () => { + const workspace = makeTempDir("t3code-ws-workspace-entries-"); + fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true }); + fs.writeFileSync(path.join(workspace, "src", "components", "Composer.tsx"), "export {};", "utf8"); + fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8"); + fs.mkdirSync(path.join(workspace, ".git"), { recursive: true }); + fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); + + server = createTestServer({ cwd: "/test" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { + cwd: workspace, + query: "comp", + limit: 10, + }); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + entries: expect.arrayContaining([ + expect.objectContaining({ path: "src/components", kind: "directory" }), + expect.objectContaining({ path: "src/components/Composer.tsx", kind: "file" }), + ]), + truncated: false, + }); + }); + it("supports git methods over websocket", async () => { const repoCwd = makeTempDir("t3code-ws-git-project-"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index a8cbbba51e572a2efd94aa8a6548719136c36991..1ffc04ba82cb6bdcbde66b545ce826fa444f9daa 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -32,6 +32,7 @@ import { } from "./git"; import { TerminalManager } from "./terminalManager"; import { loadResolvedKeybindingsConfig } from "./keybindings"; +import { searchWorkspaceEntries } from "./workspaceEntries"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -319,6 +320,9 @@ export function createServer(options: ServerOptions) { projectRegistry.remove(request.params as never); return undefined; + case WS_METHODS.projectsSearchEntries: + return searchWorkspaceEntries(request.params as never); + case WS_METHODS.shellOpenInEditor: { const params = request.params as { cwd: string; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index da90b9a86ecdb3316b003927649c917d9a837d42..bbc5e5bcf959d938847a4c1e8dfffcb791413531 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,6 +1,7 @@ import { EDITORS, type EditorId, + type ProjectEntry, ModelSlug, type NativeApi, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, @@ -31,6 +32,12 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; import { buildBootstrapInput } from "../historyBootstrap"; +import { + buildPromptInput, + buildUserVisiblePrompt, + detectComposerTrigger, + replaceTextRange, +} from "../composer-logic"; import { DEFAULT_MODEL, DEFAULT_REASONING, @@ -69,6 +76,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { ChevronDownIcon, CircleAlertIcon, + FileIcon, + FolderIcon, FolderClosedIcon, InfoIcon, LockIcon, @@ -83,6 +92,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" import { CursorIcon, Icon } from "./Icons"; import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { Badge } from "./ui/badge"; +import { Command, CommandItem, CommandList, CommandPanel } from "./ui/command"; function formatMessageMeta(createdAt: string, duration: string | null): string { if (!duration) return formatTimestamp(createdAt); @@ -103,6 +113,12 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { return "text-muted-foreground/40"; } +function basenameOfPath(pathValue: string): string { + const slashIndex = pathValue.lastIndexOf("/"); + if (slashIndex === -1) return pathValue; + return pathValue.slice(slashIndex + 1); +} + type SessionContinuityState = "resumed" | "new" | "fallback_new"; interface EnsuredSessionInfo { @@ -121,6 +137,29 @@ interface ExpandedImagePreview { name: string; } +type ComposerCommandItem = + | { + id: string; + type: "path"; + path: string; + pathKind: ProjectEntry["kind"]; + label: string; + description: string; + } + | { + id: string; + type: "slash-command"; + label: string; + description: string; + } + | { + id: string; + type: "model"; + model: ModelSlug; + label: string; + description: string; + }; + function readFileAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -146,6 +185,7 @@ export default function ChatView() { gitCreateWorktreeMutationOptions({ api, queryClient }), ); const [prompt, setPrompt] = useState(""); + const [taggedPaths, setTaggedPaths] = useState([]); const [composerImages, setComposerImages] = useState([]); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); @@ -158,6 +198,8 @@ export default function ChatView() { const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); + const [composerCursor, setComposerCursor] = useState(0); + const [composerMenuIndex, setComposerMenuIndex] = useState(0); const messagesScrollRef = useRef(null); const messagesEndRef = useRef(null); const shouldAutoScrollRef = useRef(true); @@ -268,11 +310,79 @@ export default function ChatView() { sandboxMode: "workspace-write", } as const); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; + const composerTrigger = useMemo( + () => detectComposerTrigger(prompt, composerCursor), + [prompt, composerCursor], + ); const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); const keybindingsQuery = useQuery({ ...serverConfigQueryOptions(api), select: (config) => config.keybindings, }); + const workspaceEntriesQuery = useQuery({ + queryKey: [ + "composer-workspace-entries", + gitCwd, + composerTrigger?.kind === "path" ? composerTrigger.query : "", + ], + enabled: Boolean(api && gitCwd && composerTrigger?.kind === "path"), + staleTime: 15_000, + queryFn: async () => { + if (!api || !gitCwd || composerTrigger?.kind !== "path") { + return { entries: [], truncated: false }; + } + return api.projects.searchEntries({ + cwd: gitCwd, + query: composerTrigger.query, + limit: 80, + }); + }, + }); + const composerMenuItems = useMemo(() => { + if (!composerTrigger) return []; + if (composerTrigger.kind === "path") { + return (workspaceEntriesQuery.data?.entries ?? []) + .filter((entry) => !taggedPaths.includes(entry.path)) + .map((entry) => ({ + id: `path:${entry.kind}:${entry.path}`, + type: "path", + path: entry.path, + pathKind: entry.kind, + label: basenameOfPath(entry.path), + description: entry.parentPath ?? (entry.kind === "directory" ? "Folder" : "File"), + })); + } + + if (composerTrigger.kind === "slash-command") { + if (!"model".includes(composerTrigger.query.toLowerCase())) { + return []; + } + return [ + { + id: "slash:model", + type: "slash-command", + label: "/model", + description: "Switch response model for this thread", + }, + ]; + } + + return MODEL_OPTIONS.filter(({ slug, name }) => { + const query = composerTrigger.query.trim().toLowerCase(); + if (!query) return true; + return slug.toLowerCase().includes(query) || name.toLowerCase().includes(query); + }).map(({ slug, name }) => ({ + id: `model:${slug}`, + type: "model", + model: slug, + label: name, + description: slug, + })); + }, [composerTrigger, taggedPaths, workspaceEntriesQuery.data?.entries]); + const composerMenuOpen = Boolean(composerTrigger); + const activeComposerMenuItem = + composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ?? + null; const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; @@ -438,6 +548,10 @@ export default function ChatView() { setExpandedWorkGroups({}); }, [activeThread?.id]); + useEffect(() => { + setComposerMenuIndex(0); + }, [composerTrigger?.kind, composerTrigger?.query, composerMenuItems.length]); + useEffect(() => { if (!activeThread?.id || activeThread.terminalOpen) return; const frame = window.requestAnimationFrame(() => { @@ -457,6 +571,9 @@ export default function ChatView() { revokePreviewUrls(existing); return []; }); + setTaggedPaths([]); + setComposerCursor(0); + setComposerMenuIndex(0); dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); @@ -773,7 +890,10 @@ export default function ChatView() { e.preventDefault(); if (!api || !activeThread || isSending || isConnecting) return; const trimmed = prompt.trim(); - if (!trimmed && composerImages.length === 0) return; + const taggedPathsSnapshot = [...taggedPaths]; + const promptForModel = buildPromptInput(trimmed, taggedPathsSnapshot); + const userVisiblePrompt = buildUserVisiblePrompt(trimmed, taggedPathsSnapshot); + if (!promptForModel && composerImages.length === 0) return; if (!activeProject) return; const composerImagesSnapshot = [...composerImages]; @@ -813,6 +933,7 @@ export default function ChatView() { if (activeThread.messages.length === 0) { const titleSeed = trimmed || + (taggedPathsSnapshot.length > 0 ? `Paths: ${taggedPathsSnapshot[0]}` : "") || (composerImagesSnapshot.length > 0 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` : "New thread"); @@ -837,12 +958,15 @@ export default function ChatView() { type: "PUSH_USER_MESSAGE", threadId: activeThread.id, id: crypto.randomUUID(), - text: trimmed, + text: userVisiblePrompt, ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), }); const previousMessages = activeThread.messages; setPrompt(""); setComposerImages([]); + setTaggedPaths([]); + setComposerCursor(0); + setComposerMenuIndex(0); const sessionInfo = await ensureSession(sessionCwd); if (!sessionInfo) return; @@ -863,14 +987,14 @@ export default function ChatView() { const shouldBootstrap = previousMessages.length > 0 && (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); - const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT; + const latestPromptForBootstrap = promptForModel || IMAGE_ONLY_BOOTSTRAP_PROMPT; const input = shouldBootstrap ? buildBootstrapInput( previousMessages, latestPromptForBootstrap, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ).text - : trimmed || undefined; + : promptForModel || undefined; await api.providers.sendTurn({ sessionId: sessionInfo.sessionId, ...(input ? { input } : {}), @@ -949,7 +1073,74 @@ export default function ChatView() { [scheduleComposerFocus], ); + const applyPromptReplacement = useCallback( + (rangeStart: number, rangeEnd: number, replacement: string) => { + setPrompt((existing) => { + const next = replaceTextRange(existing, rangeStart, rangeEnd, replacement); + window.requestAnimationFrame(() => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.focus(); + textarea.setSelectionRange(next.cursor, next.cursor); + setComposerCursor(next.cursor); + }); + return next.text; + }); + }, + [], + ); + + const onSelectComposerItem = useCallback( + (item: ComposerCommandItem) => { + if (!composerTrigger) return; + if (item.type === "path") { + setTaggedPaths((existing) => + existing.includes(item.path) ? existing : [...existing, item.path], + ); + applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, ""); + return; + } + if (item.type === "slash-command") { + applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, "/model "); + return; + } + onModelSelect(item.model); + applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, ""); + }, + [applyPromptReplacement, composerTrigger, onModelSelect], + ); + + const onRemoveTaggedPath = useCallback((pathToRemove: string) => { + setTaggedPaths((existing) => existing.filter((entry) => entry !== pathToRemove)); + }, []); + + const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { + setPrompt(nextPrompt); + setComposerCursor(nextCursor); + }, []); + const onKeyDown = (e: KeyboardEvent) => { + if (composerMenuOpen && composerMenuItems.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setComposerMenuIndex((existing) => Math.min(existing + 1, composerMenuItems.length - 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setComposerMenuIndex((existing) => Math.max(existing - 1, 0)); + return; + } + if (e.key === "Tab" || e.key === "Enter") { + const selectedItem = activeComposerMenuItem ?? composerMenuItems[0]; + if (selectedItem) { + e.preventDefault(); + onSelectComposerItem(selectedItem); + return; + } + } + } + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void onSend(e as unknown as FormEvent); @@ -1045,7 +1236,81 @@ export default function ChatView() { onDrop={onComposerDrop} > {/* Textarea area */} -
+
+ {composerMenuOpen && ( +
+ + + + {composerMenuItems.map((item, index) => ( + setComposerMenuIndex(index)} + onMouseDown={(event) => { + event.preventDefault(); + onSelectComposerItem(item); + }} + > + {item.type === "path" ? ( + item.pathKind === "directory" ? ( + + ) : ( + + ) + ) : null} + {item.type === "slash-command" ? ( + + ) : null} + {item.type === "model" ? ( + + model + + ) : null} + {item.label} + + {item.description} + + + ))} + + {composerMenuItems.length === 0 && ( +

+ {workspaceEntriesQuery.isLoading + ? "Searching workspace files..." + : composerTrigger?.kind === "path" + ? "No matching files or folders." + : "No matching command."} +

+ )} +
+
+
+ )} + + {taggedPaths.length > 0 && ( +
+ {taggedPaths.map((taggedPath) => ( + + ))} +
+ )} + {composerImages.length > 0 && (
{composerImages.map((image) => ( @@ -1085,13 +1350,33 @@ export default function ChatView() { className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none" rows={2} value={prompt} - onChange={(e) => setPrompt(e.target.value)} + onChange={(event) => + onPromptChange( + event.target.value, + event.target.selectionStart ?? event.target.value.length, + ) + } onKeyDown={onKeyDown} + onKeyUp={(event) => + setComposerCursor( + event.currentTarget.selectionStart ?? event.currentTarget.value.length, + ) + } + onClick={(event) => + setComposerCursor( + event.currentTarget.selectionStart ?? event.currentTarget.value.length, + ) + } + onSelect={(event) => + setComposerCursor( + event.currentTarget.selectionStart ?? event.currentTarget.value.length, + ) + } onPaste={onComposerPaste} placeholder={ phase === "disconnected" ? "Ask for follow-up changes or attach images" - : "Ask anything, or attach images..." + : "Ask anything, @tag files/folders, or use /model" } disabled={isSending || isConnecting} /> @@ -1159,7 +1444,9 @@ export default function ChatView() { type="submit" 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" disabled={ - isSending || isConnecting || (!prompt.trim() && composerImages.length === 0) + isSending || + isConnecting || + (!prompt.trim() && composerImages.length === 0 && taggedPaths.length === 0) } aria-label={ isConnecting ? "Connecting" : isSending ? "Sending" : "Send message" diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..beb9385e44bbbef06dce74b7dee3e425ddb2eba6 --- /dev/null +++ b/apps/web/src/composer-logic.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { + buildPromptInput, + buildUserVisiblePrompt, + detectComposerTrigger, + replaceTextRange, +} from "./composer-logic"; + +describe("detectComposerTrigger", () => { + it("detects @path trigger at cursor", () => { + const text = "Please check @src/com"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "path", + query: "src/com", + rangeStart: "Please check ".length, + rangeEnd: text.length, + }); + }); + + it("detects slash command token while typing command name", () => { + const text = "/mo"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "slash-command", + query: "mo", + rangeStart: 0, + rangeEnd: text.length, + }); + }); + + it("detects slash model query after /model", () => { + const text = "/model spark"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "slash-model", + query: "spark", + rangeStart: 0, + rangeEnd: text.length, + }); + }); +}); + +describe("replaceTextRange", () => { + it("replaces a text range and returns new cursor", () => { + const replaced = replaceTextRange("hello @src", 6, 10, ""); + expect(replaced).toEqual({ + text: "hello ", + cursor: 6, + }); + }); +}); + +describe("tagged path prompt helpers", () => { + it("builds model input with tagged paths", () => { + const result = buildPromptInput("Fix this", ["src/app.tsx", "src/app.tsx", "README.md"]); + expect(result).toContain("Fix this"); + expect(result).toContain("Referenced paths:"); + expect(result).toContain("- src/app.tsx"); + expect(result).toContain("- README.md"); + expect(result).not.toContain("- src/app.tsx\n- src/app.tsx"); + }); + + it("builds user-visible prompt from tags only", () => { + const result = buildUserVisiblePrompt(" ", ["src/app.tsx", "README.md"]); + expect(result).toBe("@src/app.tsx @README.md"); + }); +}); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts new file mode 100644 index 0000000000000000000000000000000000000000..db6ae79b7f2e9b175af8565eae29a576dbbd6541 --- /dev/null +++ b/apps/web/src/composer-logic.ts @@ -0,0 +1,128 @@ +export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; + +export interface ComposerTrigger { + kind: ComposerTriggerKind; + query: string; + rangeStart: number; + rangeEnd: number; +} + +function clampCursor(text: string, cursor: number): number { + if (!Number.isFinite(cursor)) return text.length; + return Math.max(0, Math.min(text.length, Math.floor(cursor))); +} + +function isWhitespace(char: string): boolean { + return char === " " || char === "\n" || char === "\t" || char === "\r"; +} + +function tokenStartForCursor(text: string, cursor: number): number { + let index = cursor - 1; + while (index >= 0 && !isWhitespace(text[index] ?? "")) { + index -= 1; + } + return index + 1; +} + +function dedupePaths(paths: string[]): string[] { + const unique = new Set(); + for (const rawPath of paths) { + const normalized = rawPath.trim(); + if (!normalized) continue; + unique.add(normalized); + } + return [...unique]; +} + +export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { + const cursor = clampCursor(text, cursorInput); + const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; + const linePrefix = text.slice(lineStart, cursor); + + if (linePrefix.startsWith("/")) { + const commandMatch = /^\/(\S*)$/.exec(linePrefix); + if (commandMatch) { + const commandQuery = commandMatch[1] ?? ""; + if (commandQuery.toLowerCase() === "model") { + return { + kind: "slash-model", + query: "", + rangeStart: lineStart, + rangeEnd: cursor, + }; + } + if ("model".startsWith(commandQuery.toLowerCase())) { + return { + kind: "slash-command", + query: commandQuery, + rangeStart: lineStart, + rangeEnd: cursor, + }; + } + return null; + } + + const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); + if (modelMatch) { + return { + kind: "slash-model", + query: (modelMatch[1] ?? "").trim(), + rangeStart: lineStart, + rangeEnd: cursor, + }; + } + } + + const tokenStart = tokenStartForCursor(text, cursor); + const token = text.slice(tokenStart, cursor); + if (!token.startsWith("@")) { + return null; + } + + return { + kind: "path", + query: token.slice(1), + rangeStart: tokenStart, + rangeEnd: cursor, + }; +} + +export function replaceTextRange( + text: string, + rangeStart: number, + rangeEnd: number, + replacement: string, +): { text: string; cursor: number } { + const safeStart = Math.max(0, Math.min(text.length, rangeStart)); + const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd)); + const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`; + return { text: nextText, cursor: safeStart + replacement.length }; +} + +export function buildPromptInput(text: string, taggedPaths: string[]): string { + const trimmedPrompt = text.trim(); + const normalizedPaths = dedupePaths(taggedPaths); + if (normalizedPaths.length === 0) { + return trimmedPrompt; + } + + const tagBlock = `Referenced paths:\n${normalizedPaths.map((entry) => `- ${entry}`).join("\n")}`; + if (!trimmedPrompt) { + return tagBlock; + } + return `${trimmedPrompt}\n\n${tagBlock}`; +} + +export function buildUserVisiblePrompt(text: string, taggedPaths: string[]): string { + const trimmedPrompt = text.trim(); + const normalizedPaths = dedupePaths(taggedPaths); + if (normalizedPaths.length === 0) { + return trimmedPrompt; + } + + const tagsSummary = normalizedPaths.map((entry) => `@${entry}`).join(" "); + if (!trimmedPrompt) { + return tagsSummary; + } + return `${trimmedPrompt}\n\n${tagsSummary}`; +} diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index e444cec0b9f1f40db101b8133094b72830d84ed8..984ec3aa42025741aa680962ecd221e59193b2da 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -98,6 +98,7 @@ export function createWsNativeApi(): NativeApi { list: () => transport.request(WS_METHODS.projectsList), add: (input) => transport.request(WS_METHODS.projectsAdd, input), remove: (input) => transport.request(WS_METHODS.projectsRemove, input), + searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), }, shell: { openInEditor: (cwd, editor) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 3c1f8c265a0cf72eae3f7f61c6f61da4c3815fcd..febfcd11fa1afe8dcecb41024840978feba2f5f6 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -29,6 +29,8 @@ import type { ProjectAddInput, ProjectAddResult, ProjectListResult, + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, ProjectRemoveInput, } from "./project"; import type { ServerConfig } from "./server"; @@ -90,6 +92,7 @@ export interface NativeApi { list: () => Promise; add: (input: ProjectAddInput) => Promise; remove: (input: ProjectRemoveInput) => Promise; + searchEntries: (input: ProjectSearchEntriesInput) => Promise; }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index b1d2d8cefe878734b51e6695e7d4a822834f5510..2c315e6bdd9b2004af4f74e0c821531ebdab0e4d 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; import { projectAddInputSchema, projectAddResultSchema, + projectSearchEntriesInputSchema, + projectSearchEntriesResultSchema, projectListResultSchema, projectRemoveInputSchema, } from "./project"; @@ -46,4 +48,38 @@ describe("project contracts", () => { const parsed = projectRemoveInputSchema.parse({ id: "project-1" }); expect(parsed.id).toBe("project-1"); }); + + it("parses workspace entry search input with defaults", () => { + const parsed = projectSearchEntriesInputSchema.parse({ + cwd: " /tmp/project ", + query: " src ", + }); + + expect(parsed).toEqual({ + cwd: "/tmp/project", + query: "src", + limit: 80, + }); + }); + + it("parses workspace entry search result", () => { + const parsed = projectSearchEntriesResultSchema.parse({ + entries: [ + { + path: "src/components", + kind: "directory", + parentPath: "src", + }, + { + path: "src/components/Button.tsx", + kind: "file", + parentPath: "src/components", + }, + ], + truncated: false, + }); + + expect(parsed.entries).toHaveLength(2); + expect(parsed.entries[0]?.kind).toBe("directory"); + }); }); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 0d46fb37cf29c41cfbbc390603b7b8177aa08075..76b5e62c535c9961451eac9709abc780f9bffaa3 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -23,8 +23,33 @@ export const projectRemoveInputSchema = z.object({ id: z.string().min(1), }); +export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; + +export const projectSearchEntriesInputSchema = z.object({ + cwd: z.string().trim().min(1), + query: z.string().trim().max(256).default(""), + limit: z.number().int().min(1).max(PROJECT_SEARCH_ENTRIES_MAX_LIMIT).default(80), +}); + +export const projectEntryKindSchema = z.enum(["file", "directory"]); + +export const projectEntrySchema = z.object({ + path: z.string().min(1), + kind: projectEntryKindSchema, + parentPath: z.string().optional(), +}); + +export const projectSearchEntriesResultSchema = z.object({ + entries: z.array(projectEntrySchema), + truncated: z.boolean(), +}); + export type ProjectRecord = z.infer; export type ProjectListResult = z.infer; export type ProjectAddInput = z.input; export type ProjectAddResult = z.infer; export type ProjectRemoveInput = z.input; +export type ProjectSearchEntriesInput = z.input; +export type ProjectEntryKind = z.infer; +export type ProjectEntry = z.infer; +export type ProjectSearchEntriesResult = z.infer; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index e37e12ed81588c833fa68e75ae5cf21a854cbace..5f0ff85789f23254d20f05b87c0dbcb150bf5d50 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -15,6 +15,7 @@ export const WS_METHODS = { projectsList: "projects.list", projectsAdd: "projects.add", projectsRemove: "projects.remove", + projectsSearchEntries: "projects.searchEntries", // Shell methods shellOpenInEditor: "shell.openInEditor",