-
Notifications
You must be signed in to change notification settings - Fork 12
t3code-inspired features: model slug, PR ahead hints, chat UX, settings #163
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
236f75a
aafb1f3
96c9035
0331e3a
10f18c2
c8f027a
84ca37f
f000eee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,35 @@ | ||
| import { executableTool as tool } from "./executableTool"; | ||
| import { z } from "zod"; | ||
| import { execFile } from "node:child_process"; | ||
| import { promisify } from "node:util"; | ||
| import { execFile, type ExecFileOptionsWithStringEncoding } from "node:child_process"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { getErrorMessage, resolvePathWithinRoot } from "../../shared/utils"; | ||
|
|
||
| const execFileAsync = promisify(execFile); | ||
| /** Swappable for Vitest — defaults to Node's `execFile`. */ | ||
| let execFileForRipgrep: typeof execFile = execFile; | ||
|
|
||
| /** @internal Used by grepSearch.test.ts to force the JS fallback path. */ | ||
| export function __testSetRipgrepExecFile(fn: typeof execFile): void { | ||
| execFileForRipgrep = fn; | ||
| } | ||
|
|
||
| /** @internal */ | ||
| export function __testResetRipgrepExecFile(): void { | ||
| execFileForRipgrep = execFile; | ||
| } | ||
|
|
||
| function execFileAsync( | ||
| file: string, | ||
| args: readonly string[] | null | undefined, | ||
| options: ExecFileOptionsWithStringEncoding, | ||
| ): Promise<{ stdout: string; stderr: string }> { | ||
| return new Promise((resolve, reject) => { | ||
| execFileForRipgrep(file, args, options, (error, stdout, stderr) => { | ||
| if (error) reject(error); | ||
| else resolve({ stdout, stderr }); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| type GrepMatch = { | ||
| path: string; | ||
|
|
@@ -77,10 +100,13 @@ export function createGrepSearchTool(cwd: string) { | |
| const matches = jsFallbackGrep(root, pattern, target, fileGlob); | ||
| return { matches, matchCount: matches.length, root: target }; | ||
| } catch (err) { | ||
| const message = getErrorMessage(err); | ||
| return { | ||
| matches: [], | ||
| matchCount: 0, | ||
| error: `Search failed: ${getErrorMessage(err)}`, | ||
| error: message.startsWith("Invalid regex pattern") | ||
| ? message | ||
| : `Search failed: ${message}`, | ||
| }; | ||
| } | ||
| }, | ||
|
|
@@ -126,9 +152,17 @@ function jsFallbackGrep( | |
| target: string, | ||
| fileGlob: string | undefined | ||
| ): GrepMatch[] { | ||
| const regex = new RegExp(pattern); | ||
| let regex: RegExp; | ||
| try { | ||
| regex = new RegExp(pattern); | ||
| } catch (error) { | ||
| // Surface a user-facing message distinct from generic "Search failed". | ||
| // Ripgrep itself returns a descriptive error for malformed patterns; match that ergonomic on the fallback path. | ||
| throw new Error(`Invalid regex pattern: ${getErrorMessage(error)}`); | ||
| } | ||
| const results: GrepMatch[] = []; | ||
| const files = collectFiles(target, fileGlob); | ||
| const searchWholeRepo = path.resolve(target) === path.resolve(root); | ||
| const files = collectFiles(target, fileGlob, searchWholeRepo); | ||
|
|
||
| for (const filePath of files) { | ||
| if (results.length >= 500) break; | ||
|
|
@@ -151,16 +185,38 @@ function jsFallbackGrep( | |
|
|
||
| const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage"]); | ||
|
|
||
| /** Hidden first-segment dirs under the repo root we still want repo-wide search to enter. */ | ||
| const ALLOW_HIDDEN_ROOT_DIRS = new Set([".github"]); | ||
|
|
||
| function shouldSkipHiddenDirUnderRepoRoot( | ||
| rootReal: string, | ||
| parentAbs: string, | ||
| dirName: string, | ||
| searchWholeRepo: boolean, | ||
| ): boolean { | ||
| if (!searchWholeRepo) return false; | ||
| if (!dirName.startsWith(".") || dirName === "." || dirName === "..") return false; | ||
| if (ALLOW_HIDDEN_ROOT_DIRS.has(dirName)) return false; | ||
| const childAbs = path.resolve(path.join(parentAbs, dirName)); | ||
| const rel = path.relative(rootReal, childAbs); | ||
| if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false; | ||
| const first = rel.split(path.sep)[0] ?? ""; | ||
| // Only skip direct children of the repo root (e.g. `.ade`, `.env`) — not `src/.cache`. | ||
| return first === dirName; | ||
| } | ||
|
|
||
| function collectFiles( | ||
| dir: string, | ||
| fileGlob: string | undefined, | ||
| searchWholeRepo: boolean, | ||
| maxFiles = 5000 | ||
| ): string[] { | ||
| const stat = fs.statSync(dir); | ||
| if (stat.isFile()) return [dir]; | ||
|
|
||
| const files: string[] = []; | ||
| const globRegex = fileGlob ? globToRegex(fileGlob) : null; | ||
| const rootReal = fs.realpathSync(dir); | ||
|
|
||
| function walk(current: string): void { | ||
| if (files.length >= maxFiles) return; | ||
|
|
@@ -173,9 +229,14 @@ function collectFiles( | |
| for (const entry of entries) { | ||
| if (files.length >= maxFiles) return; | ||
| if (entry.isDirectory()) { | ||
| if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) { | ||
| walk(path.join(current, entry.name)); | ||
| if (SKIP_DIRS.has(entry.name)) continue; | ||
| const next = path.join(current, entry.name); | ||
| if ( | ||
| shouldSkipHiddenDirUnderRepoRoot(rootReal, current, entry.name, searchWholeRepo) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [🟡 Medium] [🔵 Bug] The new walker removed the old // apps/desktop/src/main/services/ai/tools/grepSearch.ts
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
const next = path.join(current, entry.name);
if (shouldSkipHiddenDirUnderRepoRoot(rootReal, current, entry.name, searchWholeRepo)) {That changes fallback behavior from ripgrep’s default filtering ( |
||
| ) { | ||
| continue; | ||
| } | ||
| walk(next); | ||
| } else if (entry.isFile()) { | ||
| const fullPath = path.join(current, entry.name); | ||
| if (!globRegex || globRegex.test(entry.name)) { | ||
|
|
@@ -190,9 +251,16 @@ function collectFiles( | |
| } | ||
|
|
||
| function globToRegex(glob: string): RegExp { | ||
| // Globs are matched against bare filenames (`entry.name`) in the fallback, | ||
| // so strip directory components before applying glob rules. Collapse `**` | ||
| // first so `**/*.ts` → `*/*.ts` → `*.ts`; if any `/` remains, keep only the | ||
| // last segment (the filename pattern) so `src/*.ts` still matches `foo.ts`. | ||
| let pattern = glob.replace(/\*\*/g, "*"); | ||
| const lastSlash = pattern.lastIndexOf("/"); | ||
| if (lastSlash !== -1) pattern = pattern.slice(lastSlash + 1); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [🟡 Medium] [🔵 Bug]
// apps/desktop/src/main/services/ai/tools/grepSearch.ts
let pattern = glob.replace(/\*\*/g, "*");
const lastSlash = pattern.lastIndexOf("/");
if (lastSlash !== -1) pattern = pattern.slice(lastSlash + 1); |
||
| // Escape special regex chars except * and ? first, BEFORE brace expansion. | ||
| // This avoids escaping the parens/pipe that brace expansion introduces. | ||
| let pattern = glob.replace(/[.+^$[\]\\]/g, "\\$&"); | ||
| pattern = pattern.replace(/[.+^$[\]\\]/g, "\\$&"); | ||
| // Replace glob wildcards | ||
| pattern = pattern.replace(/\*/g, ".*"); | ||
| pattern = pattern.replace(/\?/g, "."); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[🟡 Medium] [🔵 Bug]
This change only skips dot-directories when
searchWholeRepois true, so targeted directory searches no longer preserve the old hidden-dir guard. In the new code:tool.execute({ path: "src", ... })will now recurse intosrc/.cache,src/.storybook, etc., whereas the previous implementation skipped allentry.name.startsWith(".")directories and the PR note says targeted subdirectory searches are unchanged. That broadens the agent-visible search surface beyond the intended.githubexception. Restore the old hidden-directory exclusion for non-root searches, and only special-case direct children of the repo root whensearchWholeRepois true.