- 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
Unified · Split
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -766,6 +766,38 @@ describe("WebSocket Server", () => { · 765 unmodified lines | ||
| 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 | |
| @@ -32,6 +32,7 @@ import { · 31 unmodified lines | ||
| 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) { · 281 unmodified lines | ||
| 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; |
| @@ -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"; · 24 unmodified lines | ||
| 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"; · 32 unmodified lines | ||
| 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" · 8 unmodified lines | ||
| 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 { · 14 unmodified lines | ||
| 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 { · 12 unmodified lines | ||
| 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() { · 19 unmodified lines | ||
| 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() { · 6 unmodified lines | ||
| 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() { · 104 unmodified lines | ||
| 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() { · 159 unmodified lines | ||
| 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() { · 13 unmodified lines | ||
| 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() { · 310 unmodified lines | ||
| 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() { · 33 unmodified lines | ||
| 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() { · 18 unmodified lines | ||
| 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() { · 14 unmodified lines | ||
| 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() { · 72 unmodified lines | ||
| 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() { · 89 unmodified lines | ||
| 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() { · 33 unmodified lines | ||
| 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() { · 61 unmodified lines | ||
| 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" |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -98,6 +98,7 @@ export function createWsNativeApi(): NativeApi { · 97 unmodified lines | ||
| 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) => |
| @@ -29,6 +29,8 @@ import type { · 28 unmodified lines | ||
| 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 { · 55 unmodified lines | ||
| 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>; |
| @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; · 2 unmodified lines | ||
| 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", () => { · 37 unmodified lines | ||
| 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 | }); |
| @@ -23,8 +23,33 @@ export const projectRemoveInputSchema = z.object({ · 22 unmodified lines | ||
| 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>; | |
| @@ -15,6 +15,7 @@ export const WS_METHODS = { · 14 unmodified lines | ||
| 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", |