Archived
fix: handle spaced file suggestions
This commit is contained in:
@@ -8,6 +8,7 @@ import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { api, type FileSuggestion, type SessionStatus, type SlashCommand } from "../api";
|
||||
import { inputModeForDraft } from "../inputModes";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
|
||||
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
|
||||
import { promptEditorStyles, type CompletionItem } from "./shared";
|
||||
import "./AutocompleteMenu";
|
||||
@@ -193,7 +194,7 @@ export class PromptEditor extends LitElement {
|
||||
this.completions = files
|
||||
.slice(0, 12)
|
||||
.map((file) => {
|
||||
const insertText = fileInsertText(file.path, trigger.quoted === true, file.path.endsWith("/") ? trigger.allPrefix : undefined);
|
||||
const insertText = fileCompletionInsertText(file.path, trigger.quoted === true, file.path.endsWith("/") ? trigger.allPrefix : undefined);
|
||||
return {
|
||||
kind: "file",
|
||||
replaceFrom: trigger.from,
|
||||
@@ -206,30 +207,8 @@ export class PromptEditor extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean } | undefined {
|
||||
const cursor = this.editor?.state.selection.main.head ?? this.draft.length;
|
||||
const beforeCursor = this.draft.slice(0, cursor);
|
||||
const quotedTrigger = this.currentQuotedTrigger(beforeCursor, cursor);
|
||||
if (quotedTrigger !== undefined) return quotedTrigger;
|
||||
|
||||
const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1;
|
||||
const token = beforeCursor.slice(tokenStart);
|
||||
const beforeToken = beforeCursor.slice(0, tokenStart);
|
||||
if (beforeToken.endsWith("@ ")) return { kind: "file", query: token, from: tokenStart - 2, to: cursor, fileScope: "all", allPrefix: "@ " };
|
||||
if (token.startsWith("/") && tokenStart === 0) return { kind: "command", query: token.slice(1), from: tokenStart, to: cursor };
|
||||
if (token.startsWith("!@")) return { kind: "file", query: token.slice(2), from: tokenStart, to: cursor, fileScope: "all", allPrefix: "!@" };
|
||||
if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor, fileScope: "tracked" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private currentQuotedTrigger(beforeCursor: string, cursor: number): { kind: "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted: true } | undefined {
|
||||
const quoteStart = beforeCursor.lastIndexOf("\"");
|
||||
if (quoteStart === -1) return undefined;
|
||||
const prefix = beforeCursor.slice(0, quoteStart);
|
||||
if (prefix.endsWith("!@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 2, to: cursor, fileScope: "all", allPrefix: "!@", quoted: true };
|
||||
if (prefix.endsWith("@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 1, to: cursor, fileScope: "tracked", quoted: true };
|
||||
if (prefix.endsWith("@ ")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 2, to: cursor, fileScope: "all", allPrefix: "@ ", quoted: true };
|
||||
return undefined;
|
||||
private currentTrigger(): PromptCompletionTrigger | undefined {
|
||||
return detectPromptCompletionTrigger(this.draft, this.editor?.state.selection.main.head ?? this.draft.length);
|
||||
}
|
||||
|
||||
private moveCompletion(delta: number): boolean {
|
||||
@@ -301,12 +280,6 @@ function draftStorageKey(machineId: unknown, sessionId: unknown): string | undef
|
||||
return machineSessionKey(machineId, sessionId);
|
||||
}
|
||||
|
||||
function fileInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string {
|
||||
const prefix = allPrefix ?? "@";
|
||||
if (!quoted && !path.includes(" ")) return `${prefix}${path}`;
|
||||
return `${prefix}"${path}"`;
|
||||
}
|
||||
|
||||
function emptySlashCommands(): SlashCommand[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ describe("inputModeForDraft", () => {
|
||||
it("detects file completion contexts", () => {
|
||||
expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" });
|
||||
expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" });
|
||||
expect(inputModeForDraft("open @ A FILE")).toEqual({ kind: "file" });
|
||||
expect(inputModeForDraft("open !@vendor/file.ts")).toEqual({ kind: "file" });
|
||||
expect(inputModeForDraft("!@vendor/file.ts")).toEqual({ kind: "file" });
|
||||
expect(inputModeForDraft("open @ \"src/main.ts")).toEqual({ kind: "file" });
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { detectPromptCompletionTrigger } from "./promptCompletions";
|
||||
|
||||
export type InputMode =
|
||||
| { kind: "normal" }
|
||||
| { kind: "command" }
|
||||
@@ -9,7 +11,7 @@ export function inputModeForDraft(draft: string): InputMode {
|
||||
if (trimmed.startsWith("!@")) return { kind: "file" };
|
||||
if (trimmed.startsWith("!")) return { kind: "shell", excludeFromContext: trimmed.startsWith("!!") };
|
||||
if (currentToken(draft).startsWith("/")) return { kind: "command" };
|
||||
if (isFileCompletionContext(draft)) return { kind: "file" };
|
||||
if (detectPromptCompletionTrigger(draft)?.kind === "file") return { kind: "file" };
|
||||
return { kind: "normal" };
|
||||
}
|
||||
|
||||
@@ -22,13 +24,3 @@ function currentToken(draft: string): string {
|
||||
return draft.slice(tokenStart);
|
||||
}
|
||||
|
||||
function isFileCompletionContext(draft: string): boolean {
|
||||
const token = currentToken(draft);
|
||||
if (token.startsWith("@") || token.startsWith("!@")) return true;
|
||||
const tokenStart = draft.length - token.length;
|
||||
if (draft.slice(0, tokenStart).endsWith("@ ")) return true;
|
||||
const quoteStart = draft.lastIndexOf("\"");
|
||||
if (quoteStart === -1) return false;
|
||||
const prefix = draft.slice(0, quoteStart);
|
||||
return prefix.endsWith("@") || prefix.endsWith("@ ") || prefix.endsWith("!@");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectPromptCompletionTrigger, fileCompletionInsertText } from "./promptCompletions";
|
||||
|
||||
describe("detectPromptCompletionTrigger", () => {
|
||||
it("keeps all-file suggestions active when an @ space query contains spaces", () => {
|
||||
expect(detectPromptCompletionTrigger("open @ A FILE")).toEqual({
|
||||
kind: "file",
|
||||
query: "A FILE",
|
||||
from: 5,
|
||||
to: 13,
|
||||
fileScope: "all",
|
||||
allPrefix: "@ ",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps !@ all-file suggestions active when the query contains spaces", () => {
|
||||
expect(detectPromptCompletionTrigger("open !@A FILE")).toEqual({
|
||||
kind: "file",
|
||||
query: "A FILE",
|
||||
from: 5,
|
||||
to: 13,
|
||||
fileScope: "all",
|
||||
allPrefix: "!@",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects quoted all-file and tracked-file queries", () => {
|
||||
expect(detectPromptCompletionTrigger("open @ \"A F")).toEqual({
|
||||
kind: "file",
|
||||
query: "A F",
|
||||
from: 5,
|
||||
to: 11,
|
||||
fileScope: "all",
|
||||
allPrefix: "@ ",
|
||||
quoted: true,
|
||||
});
|
||||
expect(detectPromptCompletionTrigger("open @\"src/main")).toEqual({
|
||||
kind: "file",
|
||||
query: "src/main",
|
||||
from: 5,
|
||||
to: 15,
|
||||
fileScope: "tracked",
|
||||
quoted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects normal tracked file and leading slash command queries", () => {
|
||||
expect(detectPromptCompletionTrigger("open @src/main")).toEqual({
|
||||
kind: "file",
|
||||
query: "src/main",
|
||||
from: 5,
|
||||
to: 14,
|
||||
fileScope: "tracked",
|
||||
});
|
||||
expect(detectPromptCompletionTrigger("/model")).toEqual({ kind: "command", query: "model", from: 0, to: 6 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileCompletionInsertText", () => {
|
||||
it("quotes completed file paths that contain spaces", () => {
|
||||
expect(fileCompletionInsertText("A FILE", false)).toBe('@"A FILE"');
|
||||
});
|
||||
|
||||
it("preserves all-file prefixes for directories so completion can continue in that scope", () => {
|
||||
expect(fileCompletionInsertText("dir with space/", false, "@ ")).toBe('@ "dir with space/"');
|
||||
expect(fileCompletionInsertText("vendor/", false, "!@")).toBe("!@vendor/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
export type PromptCompletionTrigger =
|
||||
| { kind: "command"; query: string; from: number; to: number }
|
||||
| { kind: "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean };
|
||||
|
||||
export function detectPromptCompletionTrigger(draft: string, cursor = draft.length): PromptCompletionTrigger | undefined {
|
||||
const beforeCursor = draft.slice(0, cursor);
|
||||
const quotedTrigger = currentQuotedTrigger(beforeCursor, cursor);
|
||||
if (quotedTrigger !== undefined) return quotedTrigger;
|
||||
|
||||
const allFileTrigger = currentUnquotedAllFileTrigger(beforeCursor, cursor);
|
||||
if (allFileTrigger !== undefined) return allFileTrigger;
|
||||
|
||||
const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1;
|
||||
const token = beforeCursor.slice(tokenStart);
|
||||
const beforeToken = beforeCursor.slice(0, tokenStart);
|
||||
if (beforeToken.endsWith("@ ")) return { kind: "file", query: token, from: tokenStart - 2, to: cursor, fileScope: "all", allPrefix: "@ " };
|
||||
if (token.startsWith("/") && tokenStart === 0) return { kind: "command", query: token.slice(1), from: tokenStart, to: cursor };
|
||||
if (token.startsWith("!@")) return { kind: "file", query: token.slice(2), from: tokenStart, to: cursor, fileScope: "all", allPrefix: "!@" };
|
||||
if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor, fileScope: "tracked" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function fileCompletionInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string {
|
||||
const prefix = allPrefix ?? "@";
|
||||
if (!quoted && !path.includes(" ")) return `${prefix}${path}`;
|
||||
return `${prefix}"${path}"`;
|
||||
}
|
||||
|
||||
function currentQuotedTrigger(beforeCursor: string, cursor: number): PromptCompletionTrigger | undefined {
|
||||
const quoteStart = beforeCursor.lastIndexOf("\"");
|
||||
if (quoteStart === -1) return undefined;
|
||||
const prefix = beforeCursor.slice(0, quoteStart);
|
||||
if (prefix.endsWith("!@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 2, to: cursor, fileScope: "all", allPrefix: "!@", quoted: true };
|
||||
if (prefix.endsWith("@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 1, to: cursor, fileScope: "tracked", quoted: true };
|
||||
if (prefix.endsWith("@ ")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 2, to: cursor, fileScope: "all", allPrefix: "@ ", quoted: true };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function currentUnquotedAllFileTrigger(beforeCursor: string, cursor: number): PromptCompletionTrigger | undefined {
|
||||
const lineStart = beforeCursor.lastIndexOf("\n") + 1;
|
||||
const line = beforeCursor.slice(lineStart);
|
||||
const atSpaceIndex = lastTokenBoundarySequence(line, "@ ");
|
||||
const bangAtIndex = lastTokenBoundarySequence(line, "!@");
|
||||
const prefixStartInLine = Math.max(atSpaceIndex, bangAtIndex);
|
||||
if (prefixStartInLine === -1) return undefined;
|
||||
|
||||
const allPrefix: "@ " | "!@" = prefixStartInLine === bangAtIndex ? "!@" : "@ ";
|
||||
const from = lineStart + prefixStartInLine;
|
||||
const queryStart = from + allPrefix.length;
|
||||
return { kind: "file", query: beforeCursor.slice(queryStart), from, to: cursor, fileScope: "all", allPrefix };
|
||||
}
|
||||
|
||||
function lastTokenBoundarySequence(text: string, sequence: string): number {
|
||||
for (let index = text.lastIndexOf(sequence); index >= 0; index = text.lastIndexOf(sequence, index - 1)) {
|
||||
if (index === 0 || isWhitespace(text[index - 1])) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function isWhitespace(value: string | undefined): boolean {
|
||||
return value === " " || value === "\t";
|
||||
}
|
||||
Reference in New Issue
Block a user