| author | |
| committer | |
| log | e8556bcff95f4bdd74d4a7334626056f1e26a856 |
| tree | 987218d32eaf5c55da90efb65ed80c8bc2e07e46 |
| parent | a1532d5b2de71e4a2c3d90d9653da02fa931d8d6 |
- 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 tests12 files changed, 845 insertions(+), 8 deletions(-)
apps/server/src/workspaceEntries.test.ts created+62| ... | ... | @@ -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 created+186| ... | ... | @@ -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| ... | ... | @@ -766,6 +766,38 @@ describe("WebSocket Server", () => { |
| 766 | 766 | expect(afterRemove.result).toEqual([]); |
| 767 | 767 | }); |
| 768 | 768 | |
| 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 | ||
| 769 | 801 | it("supports git methods over websocket", async () => { |
| 770 | 802 | const repoCwd = makeTempDir("t3code-ws-git-project-"); |
| 771 | 803 |
apps/server/src/wsServer.ts+4| ... | ... | @@ -32,6 +32,7 @@ import { |
| 32 | 32 | } from "./git"; |
| 33 | 33 | import { TerminalManager } from "./terminalManager"; |
| 34 | 34 | import { loadResolvedKeybindingsConfig } from "./keybindings"; |
| 35 | import { searchWorkspaceEntries } from "./workspaceEntries"; | |
| 35 | 36 | |
| 36 | 37 | const MIME_TYPES: Record<string, string> = { |
| 37 | 38 | ".html": "text/html; charset=utf-8", |
| ... | ... | @@ -319,6 +320,9 @@ export function createServer(options: ServerOptions) { |
| 319 | 320 | projectRegistry.remove(request.params as never); |
| 320 | 321 | return undefined; |
| 321 | 322 | |
| 323 | case WS_METHODS.projectsSearchEntries: | |
| 324 | return searchWorkspaceEntries(request.params as never); | |
| 325 | ||
| 322 | 326 | case WS_METHODS.shellOpenInEditor: { |
| 323 | 327 | const params = request.params as { |
| 324 | 328 | cwd: string; |
apps/web/src/components/ChatView.tsx+295-8| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { |
| 2 | 2 | EDITORS, |
| 3 | 3 | type EditorId, |
| 4 | type ProjectEntry, | |
| 4 | 5 | ModelSlug, |
| 5 | 6 | type NativeApi, |
| 6 | 7 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| ... | ... | @@ -31,6 +32,12 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; |
| 31 | 32 | |
| 32 | 33 | import { isElectron } from "../env"; |
| 33 | 34 | import { buildBootstrapInput } from "../historyBootstrap"; |
| 35 | import { | |
| 36 | buildPromptInput, | |
| 37 | buildUserVisiblePrompt, | |
| 38 | detectComposerTrigger, | |
| 39 | replaceTextRange, | |
| 40 | } from "../composer-logic"; | |
| 34 | 41 | import { |
| 35 | 42 | DEFAULT_MODEL, |
| 36 | 43 | DEFAULT_REASONING, |
| ... | ... | @@ -69,6 +76,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; |
| 69 | 76 | import { |
| 70 | 77 | ChevronDownIcon, |
| 71 | 78 | CircleAlertIcon, |
| 79 | FileIcon, | |
| 80 | FolderIcon, | |
| 72 | 81 | FolderClosedIcon, |
| 73 | 82 | InfoIcon, |
| 74 | 83 | LockIcon, |
| ... | ... | @@ -83,6 +92,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" |
| 83 | 92 | import { CursorIcon, Icon } from "./Icons"; |
| 84 | 93 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 85 | 94 | import { Badge } from "./ui/badge"; |
| 95 | import { Command, CommandItem, CommandList, CommandPanel } from "./ui/command"; | |
| 86 | 96 | |
| 87 | 97 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 88 | 98 | if (!duration) return formatTimestamp(createdAt); |
| ... | ... | @@ -103,6 +113,12 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 103 | 113 | return "text-muted-foreground/40"; |
| 104 | 114 | } |
| 105 | 115 | |
| 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 | ||
| 106 | 122 | type SessionContinuityState = "resumed" | "new" | "fallback_new"; |
| 107 | 123 | |
| 108 | 124 | interface EnsuredSessionInfo { |
| ... | ... | @@ -121,6 +137,29 @@ interface ExpandedImagePreview { |
| 121 | 137 | name: string; |
| 122 | 138 | } |
| 123 | 139 | |
| 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 | ||
| 124 | 163 | function readFileAsDataUrl(file: File): Promise<string> { |
| 125 | 164 | return new Promise((resolve, reject) => { |
| 126 | 165 | const reader = new FileReader(); |
| ... | ... | @@ -146,6 +185,7 @@ export default function ChatView() { |
| 146 | 185 | gitCreateWorktreeMutationOptions({ api, queryClient }), |
| 147 | 186 | ); |
| 148 | 187 | const [prompt, setPrompt] = useState(""); |
| 188 | const [taggedPaths, setTaggedPaths] = useState<string[]>([]); | |
| 149 | 189 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); |
| 150 | 190 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 151 | 191 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| ... | ... | @@ -158,6 +198,8 @@ export default function ChatView() { |
| 158 | 198 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| 159 | 199 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 160 | 200 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 201 | const [composerCursor, setComposerCursor] = useState(0); | |
| 202 | const [composerMenuIndex, setComposerMenuIndex] = useState(0); | |
| 161 | 203 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 162 | 204 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 163 | 205 | const shouldAutoScrollRef = useRef(true); |
| ... | ... | @@ -268,11 +310,79 @@ export default function ChatView() { |
| 268 | 310 | sandboxMode: "workspace-write", |
| 269 | 311 | } as const); |
| 270 | 312 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 313 | const composerTrigger = useMemo( | |
| 314 | () => detectComposerTrigger(prompt, composerCursor), | |
| 315 | [prompt, composerCursor], | |
| 316 | ); | |
| 271 | 317 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); |
| 272 | 318 | const keybindingsQuery = useQuery({ |
| 273 | 319 | ...serverConfigQueryOptions(api), |
| 274 | 320 | select: (config) => config.keybindings, |
| 275 | 321 | }); |
| 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; | |
| 276 | 386 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; |
| 277 | 387 | // Default true while loading to avoid toolbar flicker. |
| 278 | 388 | const isGitRepo = branchesQuery.data?.isRepo ?? true; |
| ... | ... | @@ -438,6 +548,10 @@ export default function ChatView() { |
| 438 | 548 | setExpandedWorkGroups({}); |
| 439 | 549 | }, [activeThread?.id]); |
| 440 | 550 | |
| 551 | useEffect(() => { | |
| 552 | setComposerMenuIndex(0); | |
| 553 | }, [composerTrigger?.kind, composerTrigger?.query, composerMenuItems.length]); | |
| 554 | ||
| 441 | 555 | useEffect(() => { |
| 442 | 556 | if (!activeThread?.id || activeThread.terminalOpen) return; |
| 443 | 557 | const frame = window.requestAnimationFrame(() => { |
| ... | ... | @@ -457,6 +571,9 @@ export default function ChatView() { |
| 457 | 571 | revokePreviewUrls(existing); |
| 458 | 572 | return []; |
| 459 | 573 | }); |
| 574 | setTaggedPaths([]); | |
| 575 | setComposerCursor(0); | |
| 576 | setComposerMenuIndex(0); | |
| 460 | 577 | dragDepthRef.current = 0; |
| 461 | 578 | setIsDragOverComposer(false); |
| 462 | 579 | setExpandedImage(null); |
| ... | ... | @@ -773,7 +890,10 @@ export default function ChatView() { |
| 773 | 890 | e.preventDefault(); |
| 774 | 891 | if (!api || !activeThread || isSending || isConnecting) return; |
| 775 | 892 | 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; | |
| 777 | 897 | if (!activeProject) return; |
| 778 | 898 | const composerImagesSnapshot = [...composerImages]; |
| 779 | 899 | |
| ... | ... | @@ -813,6 +933,7 @@ export default function ChatView() { |
| 813 | 933 | if (activeThread.messages.length === 0) { |
| 814 | 934 | const titleSeed = |
| 815 | 935 | trimmed || |
| 936 | (taggedPathsSnapshot.length > 0 ? `Paths: ${taggedPathsSnapshot[0]}` : "") || | |
| 816 | 937 | (composerImagesSnapshot.length > 0 |
| 817 | 938 | ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` |
| 818 | 939 | : "New thread"); |
| ... | ... | @@ -837,12 +958,15 @@ export default function ChatView() { |
| 837 | 958 | type: "PUSH_USER_MESSAGE", |
| 838 | 959 | threadId: activeThread.id, |
| 839 | 960 | id: crypto.randomUUID(), |
| 840 | text: trimmed, | |
| 961 | text: userVisiblePrompt, | |
| 841 | 962 | ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), |
| 842 | 963 | }); |
| 843 | 964 | const previousMessages = activeThread.messages; |
| 844 | 965 | setPrompt(""); |
| 845 | 966 | setComposerImages([]); |
| 967 | setTaggedPaths([]); | |
| 968 | setComposerCursor(0); | |
| 969 | setComposerMenuIndex(0); | |
| 846 | 970 | |
| 847 | 971 | const sessionInfo = await ensureSession(sessionCwd); |
| 848 | 972 | if (!sessionInfo) return; |
| ... | ... | @@ -863,14 +987,14 @@ export default function ChatView() { |
| 863 | 987 | const shouldBootstrap = |
| 864 | 988 | previousMessages.length > 0 && |
| 865 | 989 | (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); |
| 866 | const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT; | |
| 990 | const latestPromptForBootstrap = promptForModel || IMAGE_ONLY_BOOTSTRAP_PROMPT; | |
| 867 | 991 | const input = shouldBootstrap |
| 868 | 992 | ? buildBootstrapInput( |
| 869 | 993 | previousMessages, |
| 870 | 994 | latestPromptForBootstrap, |
| 871 | 995 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 872 | 996 | ).text |
| 873 | : trimmed || undefined; | |
| 997 | : promptForModel || undefined; | |
| 874 | 998 | await api.providers.sendTurn({ |
| 875 | 999 | sessionId: sessionInfo.sessionId, |
| 876 | 1000 | ...(input ? { input } : {}), |
| ... | ... | @@ -949,7 +1073,74 @@ export default function ChatView() { |
| 949 | 1073 | [scheduleComposerFocus], |
| 950 | 1074 | ); |
| 951 | 1075 | |
| 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 | ||
| 952 | 1122 | 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 | ||
| 953 | 1144 | if (e.key === "Enter" && !e.shiftKey) { |
| 954 | 1145 | e.preventDefault(); |
| 955 | 1146 | void onSend(e as unknown as FormEvent); |
| ... | ... | @@ -1045,7 +1236,81 @@ export default function ChatView() { |
| 1045 | 1236 | onDrop={onComposerDrop} |
| 1046 | 1237 | > |
| 1047 | 1238 | {/* 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 | ||
| 1049 | 1314 | {composerImages.length > 0 && ( |
| 1050 | 1315 | <div className="mb-3 flex flex-wrap gap-2"> |
| 1051 | 1316 | {composerImages.map((image) => ( |
| ... | ... | @@ -1085,13 +1350,33 @@ export default function ChatView() { |
| 1085 | 1350 | className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none" |
| 1086 | 1351 | rows={2} |
| 1087 | 1352 | 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 | } | |
| 1089 | 1359 | 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 | } | |
| 1090 | 1375 | onPaste={onComposerPaste} |
| 1091 | 1376 | placeholder={ |
| 1092 | 1377 | phase === "disconnected" |
| 1093 | 1378 | ? "Ask for follow-up changes or attach images" |
| 1094 | : "Ask anything, or attach images..." | |
| 1379 | : "Ask anything, @tag files/folders, or use /model" | |
| 1095 | 1380 | } |
| 1096 | 1381 | disabled={isSending || isConnecting} |
| 1097 | 1382 | /> |
| ... | ... | @@ -1159,7 +1444,9 @@ export default function ChatView() { |
| 1159 | 1444 | type="submit" |
| 1160 | 1445 | 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" |
| 1161 | 1446 | disabled={ |
| 1162 | isSending || isConnecting || (!prompt.trim() && composerImages.length === 0) | |
| 1447 | isSending || | |
| 1448 | isConnecting || | |
| 1449 | (!prompt.trim() && composerImages.length === 0 && taggedPaths.length === 0) | |
| 1163 | 1450 | } |
| 1164 | 1451 | aria-label={ |
| 1165 | 1452 | isConnecting ? "Connecting" : isSending ? "Sending" : "Send message" |
apps/web/src/composer-logic.test.ts created+72| ... | ... | @@ -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 created+128| ... | ... | @@ -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| ... | ... | @@ -98,6 +98,7 @@ export function createWsNativeApi(): NativeApi { |
| 98 | 98 | list: () => transport.request(WS_METHODS.projectsList), |
| 99 | 99 | add: (input) => transport.request(WS_METHODS.projectsAdd, input), |
| 100 | 100 | remove: (input) => transport.request(WS_METHODS.projectsRemove, input), |
| 101 | searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), | |
| 101 | 102 | }, |
| 102 | 103 | shell: { |
| 103 | 104 | openInEditor: (cwd, editor) => |
packages/contracts/src/ipc.ts+3| ... | ... | @@ -29,6 +29,8 @@ import type { |
| 29 | 29 | ProjectAddInput, |
| 30 | 30 | ProjectAddResult, |
| 31 | 31 | ProjectListResult, |
| 32 | ProjectSearchEntriesInput, | |
| 33 | ProjectSearchEntriesResult, | |
| 32 | 34 | ProjectRemoveInput, |
| 33 | 35 | } from "./project"; |
| 34 | 36 | import type { ServerConfig } from "./server"; |
| ... | ... | @@ -90,6 +92,7 @@ export interface NativeApi { |
| 90 | 92 | list: () => Promise<ProjectListResult>; |
| 91 | 93 | add: (input: ProjectAddInput) => Promise<ProjectAddResult>; |
| 92 | 94 | remove: (input: ProjectRemoveInput) => Promise<void>; |
| 95 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; | |
| 93 | 96 | }; |
| 94 | 97 | shell: { |
| 95 | 98 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; |
packages/contracts/src/project.test.ts+36| ... | ... | @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; |
| 3 | 3 | import { |
| 4 | 4 | projectAddInputSchema, |
| 5 | 5 | projectAddResultSchema, |
| 6 | projectSearchEntriesInputSchema, | |
| 7 | projectSearchEntriesResultSchema, | |
| 6 | 8 | projectListResultSchema, |
| 7 | 9 | projectRemoveInputSchema, |
| 8 | 10 | } from "./project"; |
| ... | ... | @@ -46,4 +48,38 @@ describe("project contracts", () => { |
| 46 | 48 | const parsed = projectRemoveInputSchema.parse({ id: "project-1" }); |
| 47 | 49 | expect(parsed.id).toBe("project-1"); |
| 48 | 50 | }); |
| 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 | }); | |
| 49 | 85 | }); |
packages/contracts/src/project.ts+25| ... | ... | @@ -23,8 +23,33 @@ export const projectRemoveInputSchema = z.object({ |
| 23 | 23 | id: z.string().min(1), |
| 24 | 24 | }); |
| 25 | 25 | |
| 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 | ||
| 26 | 47 | export type ProjectRecord = z.infer<typeof projectRecordSchema>; |
| 27 | 48 | export type ProjectListResult = z.infer<typeof projectListResultSchema>; |
| 28 | 49 | export type ProjectAddInput = z.input<typeof projectAddInputSchema>; |
| 29 | 50 | export type ProjectAddResult = z.infer<typeof projectAddResultSchema>; |
| 30 | 51 | 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| ... | ... | @@ -15,6 +15,7 @@ export const WS_METHODS = { |
| 15 | 15 | projectsList: "projects.list", |
| 16 | 16 | projectsAdd: "projects.add", |
| 17 | 17 | projectsRemove: "projects.remove", |
| 18 | projectsSearchEntries: "projects.searchEntries", | |
| 18 | 19 | |
| 19 | 20 | // Shell methods |
| 20 | 21 | shellOpenInEditor: "shell.openInEditor", |