Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,39 @@ describe("AdeCodeApp polling", () => {

await unmountApp(instance);
});

it("closes a confirmed spaced-file mention while typing trailing prose", async () => {
const actionMock = vi.fn(async (domain: string, action: string) => {
if (domain === "file" && action === "quickOpen") return [{ path: "src/my folder" }];
return [];
});
connection.action = actionMock as unknown as AdeCodeConnection["action"];

const instance = await renderApp(<AdeCodeApp project={project} />);

await act(async () => {
instance.stdin.write("@src/my");
});
await flushInkFrame();
await act(async () => {
await vi.advanceTimersByTimeAsync(MENTION_REMOTE_DEBOUNCE_MS);
});
await flushAsyncEffects();

expect(actionMock.mock.calls.filter(([domain, action]) => domain === "file" && action === "quickOpen"))
.toHaveLength(1);

await act(async () => {
instance.stdin.write("\t");
await flushAsyncEffects();
instance.stdin.write(" review this");
});
await flushInkFrame();

expect(actionMock.mock.calls.filter(([domain, action]) => domain === "file" && action === "quickOpen"))
.toHaveLength(1);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
await unmountApp(instance);
});
});

describe("TUI product analytics policy", () => {
Expand Down
68 changes: 45 additions & 23 deletions apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@ import { resolveStableLaneBaseBranch } from "../../../desktop/src/shared/laneBas
import { LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, resolveClaudeCliModelForLaunch } from "../../../desktop/src/shared/cliLaunch";
import { getAgentSkillRootCandidates } from "../../../desktop/src/shared/agentSkillRoots";
import {
composerFileSearchQuery,
composerTriggerForSelection,
composerTriggerHasConfirmedPrefix,
composerTriggerSpansWholeDraft,
detectComposerTrigger,
findConfirmedComposerTokens,
replaceComposerTriggerSpan,
type ComposerTokenRange,
} from "../../../desktop/src/shared/composerTriggers";
import { isChatMentionTokenBody } from "../../../desktop/src/shared/chatMentions";
import { findSmartLinks } from "../../../desktop/src/shared/smartLinks";
import type {
AgentChatClaudePlugin,
Expand Down Expand Up @@ -2512,6 +2516,10 @@ export const MENTION_MAX_ROWS = 10;
export const MENTION_FILE_ROWS = 5;
const STARTUP_RECONNECT_DELAY_MS = 3_000;

function matchesMentionTarget(target: string, query: string): boolean {
return target.includes(query) || query.startsWith(`${target} `);
Comment on lines +2519 to +2520

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply trailing-prose matching to commit suggestions

In ADE Code, the new keep-alive matching is applied to lane/chat suggestions and PR titles, but the recent-commit filter still checks subject.includes(query). Consequently, after typing an exact commit subject followed by prose, such as @Fix parser please inspect, that commit disappears before Enter can select it, unlike the other newly supported multiword targets. Use this helper for commit subjects as well so selection can preserve the trailing prose.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rank the longest confirmed ADE Code target first

When target labels overlap, this predicate treats every confirmed prefix equally. For example, with a lane named Foo and a commit titled Foo Bar, the query @Foo Bar please keeps both candidates; publishSuggestions at line 7400 always places local lane/chat rows before remote commit/PR rows, so Enter selects Foo and leaves Bar please instead of selecting the exact longer target. Fresh evidence beyond the earlier shared mention-title report is that this separate ADE Code helper also governs commit and PR matches without carrying prefix length into ordering; rank the longest confirmed label before preserving the existing source order.

Useful? React with 👍 / 👎.

}

type MentionRemoteCacheEntry = {
filesByQuery: Map<string, Array<{ path: string }>>;
commits: Array<Record<string, unknown>> | null;
Expand Down Expand Up @@ -4964,9 +4972,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
setSelectedDrawerChatAction(action);
applyDrawerChatSelection({ session: session ?? null, action });
}, [applyDrawerChatSelection, openDrawerSessions, selectActiveLaneId]);
const activeComposerTrigger = useMemo(() => (
activePane === "chat" ? detectComposerTrigger(prompt, promptCursor) : null
), [activePane, prompt, promptCursor]);
const activeComposerTrigger = useMemo(() => {
if (activePane !== "chat") return null;
const trigger = detectComposerTrigger(prompt, promptCursor);
if (!trigger) return null;
const confirmedFile = (body: string) => selectedMentions.some(
(mention) => mention.kind === "file" && mention.insertText === `@${body}`,
);
return composerTriggerHasConfirmedPrefix(prompt, trigger, {
isFile: confirmedFile,
isMention: isChatMentionTokenBody,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Terminate commit and PR tokens in the TUI

After selecting a commit or PR suggestion, the inserted @commit:<sha> or @pr:<id> is not accepted by isChatMentionTokenBody, while confirmedFile only recognizes file rows. The new space-consuming trigger therefore remains active, renders an empty mention palette, and makes arrow keys palette-owned instead of moving through the prompt. Fresh evidence beyond the earlier confirmed-token report is that the same TUI still creates commit and PR suggestions in loadRemoteSuggestions; recognize every selectable canonical token or confirm it from selectedMentions.

Useful? React with 👍 / 👎.

}) ? null : trigger;
}, [activePane, prompt, promptCursor, selectedMentions]);
const activeMentionRange = useMemo(() => (
activeComposerTrigger?.type === "at"
? { start: activeComposerTrigger.start, query: activeComposerTrigger.query }
Expand Down Expand Up @@ -7339,7 +7356,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
return;
}
let cancelled = false;
const query = range.query.toLowerCase();
const query = range.query.trim().toLowerCase();
const fileQuery = composerFileSearchQuery(range.query).toLowerCase();
const matchesMentionQuery = (suggestion: MentionSuggestion): boolean => {
if (!query) return true;
const label = suggestion.label.toLowerCase();
return (
matchesMentionTarget(label, query)
|| suggestion.insertText.toLowerCase().includes(query)
|| Boolean(suggestion.detail?.toLowerCase().includes(query))
);
};
const localSuggestions = (): MentionSuggestion[] => [
...lanes.map((lane) => ({
kind: "lane" as const,
Expand All @@ -7353,20 +7380,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
insertText: `@chat:${session.sessionId}`,
detail: session.laneId,
})),
].filter((suggestion) => (
!query
|| suggestion.label.toLowerCase().includes(query)
|| suggestion.insertText.toLowerCase().includes(query)
|| suggestion.detail?.toLowerCase().includes(query)
));
].filter(matchesMentionQuery);
const attachedSuggestions = (): MentionSuggestion[] => selectedMentions
.filter((suggestion) => suggestion.attachment && suggestion.filePath)
.filter((suggestion) => (
!query
|| suggestion.label.toLowerCase().includes(query)
|| suggestion.insertText.toLowerCase().includes(query)
|| suggestion.detail?.toLowerCase().includes(query)
));
.filter(matchesMentionQuery);

const publishSuggestions = (remote: MentionSuggestion[] = []) => {
if (cancelled) return;
Expand All @@ -7390,16 +7407,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
// (shallowest paths first) instead of returning nothing, matching the
// desktop composer's `@` behavior. The cache keys on the query string,
// so "" caches like any typed query.
const filesPromise = cache.filesByQuery.get(query)
? Promise.resolve(cache.filesByQuery.get(query)!)
const filesPromise = cache.filesByQuery.get(fileQuery)
? Promise.resolve(cache.filesByQuery.get(fileQuery)!)
: Promise.resolve(conn.action<Array<{ path: string }>>("file", "quickOpen", {
workspaceId: laneId,
query,
query: fileQuery,
limit: MENTION_FILE_ROWS,
}))
.then((files) => {
const safeFiles = Array.isArray(files) ? files : [];
cache.filesByQuery.set(query, safeFiles);
cache.filesByQuery.set(fileQuery, safeFiles);
return safeFiles;
})
.catch(() => []);
Expand Down Expand Up @@ -7452,7 +7469,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
.filter((pr) => {
const title = String(pr.title ?? "");
const number = String(pr.number ?? pr.prNumber ?? "");
return !query || title.toLowerCase().includes(query) || number.includes(query);
const loweredTitle = title.toLowerCase();
return !query || matchesMentionTarget(loweredTitle, query) || number.includes(query);
})
.slice(0, 5)
.map((pr) => {
Expand Down Expand Up @@ -12331,8 +12349,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
}, [addNotice, chatRowBudget, lanes, models, refreshState, registerOptimisticTerminalSession, selectedMentions, setChatScrollOffset, setDraftChatMode, terminalPaneWidth]);

const insertMention = useCallback((suggestion: MentionSuggestion) => {
const trigger = detectComposerTrigger(prompt, promptCursorRef.current);
if (trigger?.type !== "at") return;
const detectedTrigger = detectComposerTrigger(prompt, promptCursorRef.current);
if (detectedTrigger?.type !== "at") return;
const trigger = composerTriggerForSelection(
detectedTrigger,
suggestion.kind === "file" ? suggestion.filePath ?? suggestion.label : suggestion.label,
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
const next = replaceComposerTriggerSpan(prompt, trigger, `${suggestion.insertText} `);
setPromptValue(next.text, next.caret);
setSelectedMentions((prev) => {
Expand Down
36 changes: 31 additions & 5 deletions apps/desktop/src/main/services/files/fileSearchIndexService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,41 @@ function scoreBrowseDepth(normalizedPath: string): number {
return Math.max(1, BROWSE_BASE_SCORE - depth);
}

function scorePathForNeedle(normalized: string, needle: string): number {
if (normalized === needle) return 1000;
if (normalized.endsWith(`/${needle}`) || normalized.endsWith(`\\${needle}`)) return 900;
const idx = normalized.indexOf(needle);
return idx < 0 ? -1 : 600 - idx;
}

function scorePath(pathValue: string, query: string): number {
const normalized = pathValue.toLowerCase();
const needle = query.toLowerCase().trim();
if (!needle) return scoreBrowseDepth(normalized);
if (normalized === needle) return 1000;
if (normalized.endsWith(`/${needle}`) || normalized.endsWith(`\\${needle}`)) return 900;
const idx = normalized.indexOf(needle);
if (idx < 0) return -1;
return 600 - idx;
const directScore = scorePathForNeedle(normalized, needle);
if (directScore >= 0) return directScore;

// Composer @-file queries can contain ordinary prose after an extensionless
// path whose filename or directory contains spaces. A path index cannot
// know that boundary from the string alone, so try progressively shorter
// space-delimited prefixes and keep the longest matching one. Restrict this
// fallback to path-like queries so ordinary multiword quick-open searches
// keep their existing whole-query semantics.
const isRootLevelPath = !normalized.includes("/") && !normalized.includes("\\");
if (!needle.includes("/") && !needle.includes("\\") && !isRootLevelPath) return -1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep composer prefix fallback out of generic quick-open

When a generic multiword query is used in the Files overlay or global ADE search, this branch treats every root-level file as eligible for composer-style trailing-prose matching. For example, searching for package manager now returns a root package.json because it starts with the shortened prefix package, even though the complete query does not occur in the path; before this change it returned no filename match. Make this fallback an explicit composer-only quick-open mode rather than changing shared quick-open semantics.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow nested extensionless basename matches before prose

When a nested extensionless file is referenced by basename and followed by prose—for example @README review this for docs/README—the full query has no direct match, and this guard rejects the indexed path because the query has no separator while the path does. The suggestion therefore disappears in the desktop, ADE Code, and iOS composers even though ordinary quick-open can match the same basename; apply the composer fallback to nested basename prefixes as well as root-level files.

Useful? React with 👍 / 👎.

const words = needle.split(/[ \t]+/);
let best = -1;
for (let end = words.length - 1; end > 0; end -= 1) {
const prefix = words.slice(0, end).join(" ");
const score = isRootLevelPath
? (normalized.startsWith(prefix) ? 600 : -1)
: scorePathForNeedle(normalized, prefix);
if (score < 0) continue;
// Prefer a longer path prefix when multiple indexed paths share the same
// beginning. The tiny fractional tie-break preserves existing score tiers.
best = Math.max(best, score + Math.min(prefix.length, 999) / 1000);
Comment on lines +119 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep exact full-path matches above prefix fallbacks

When an extensionless path is itself a prefix of another valid path containing spaces—for example files src/foo and src/foo bar with query src/foo bar—the full path receives the normal exact score of 1000, while the shorter file reaches this fallback and receives 1000.007. Sorting therefore places src/foo above the exact requested file, so pressing Enter in quick-open or the composer can attach the wrong file; keep fallback scores below an exact full-query match while using the fractional value only to break ties within the fallback tier.

Useful? React with 👍 / 👎.

}
return best;
}

async function cooperativeYield(): Promise<void> {
Expand Down
45 changes: 45 additions & 0 deletions apps/desktop/src/main/services/files/fileService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,51 @@ describe("fileService", () => {
}
});

it("matches an extensionless path with spaces before trailing prose", async () => {
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-spaced-path-"));
const { execSync } = await import("node:child_process");
execSync("git init", { cwd: rootPath, stdio: "ignore" });
const laneService = createLaneServiceStub(rootPath);
const service = createFileService({ laneService });

try {
fs.mkdirSync(path.join(rootPath, "src"), { recursive: true });
fs.writeFileSync(path.join(rootPath, "src", "my folder"), "extensionless path\n", "utf8");

const quickOpen = await service.quickOpen({
workspaceId: "workspace-1",
query: "src/my folder about this",
includeIgnored: true,
});

expect(quickOpen.map((item) => item.path)).toContain("src/my folder");
} finally {
removeTestTree(rootPath);
}
});

it("matches a root-level extensionless file before trailing prose", async () => {
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-root-spaced-prose-"));
const { execSync } = await import("node:child_process");
execSync("git init", { cwd: rootPath, stdio: "ignore" });
const laneService = createLaneServiceStub(rootPath);
const service = createFileService({ laneService });

try {
fs.writeFileSync(path.join(rootPath, "README"), "extensionless root file\n", "utf8");

const quickOpen = await service.quickOpen({
workspaceId: "workspace-1",
query: "README review this",
includeIgnored: true,
});

expect(quickOpen.map((item) => item.path)).toContain("README");
} finally {
removeTestTree(rootPath);
}
});

it("warms the quick open index for subsequent lookups", async () => {
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-warm-search-"));
const { execSync } = await import("node:child_process");
Expand Down
Loading
Loading