From 5053d9db640607e1469e7512d180fa93eb04b70a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 7 May 2026 23:33:40 +0200 Subject: [PATCH] Improve web file completion continuation --- src/client/src/components/PromptEditor.ts | 46 +++++++++++++++++++++-- src/client/src/components/shared.ts | 1 + src/client/src/inputModes.ts | 6 ++- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 52f3e7d..86dbd13 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -107,13 +107,25 @@ export class PromptEditor extends LitElement { if (version !== this.requestVersion) return; this.completions = files .slice(0, 12) - .map((file) => ({ kind: "file", replaceFrom: trigger.from, replaceTo: trigger.to, insertText: `${trigger.fileMode === "path" ? "" : "@"}${file.path}`, detail: file.kind })); + .map((file) => { + const insertText = fileInsertText(file.path, trigger.fileMode === "path", trigger.quoted === true); + return { + kind: "file", + replaceFrom: trigger.from, + replaceTo: trigger.to, + insertText, + detail: file.kind, + ...(file.path.endsWith("/") && insertText.endsWith("\"") ? { cursorOffset: insertText.length - 1 } : {}), + }; + }); } } - private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileKind?: FileSuggestion["kind"]; fileMode?: "file" | "path" } | undefined { + private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileKind?: FileSuggestion["kind"]; fileMode?: "file" | "path"; quoted?: boolean } | undefined { const cursor = this.textarea?.selectionStart ?? 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); @@ -124,6 +136,15 @@ export class PromptEditor extends LitElement { return undefined; } + private currentQuotedTrigger(beforeCursor: string, cursor: number): { kind: "file"; query: string; from: number; to: number; fileMode?: "file" | "path"; 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 - 1, to: cursor, quoted: true }; + if (prefix.endsWith("@ ")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: quoteStart, to: cursor, fileMode: "path", quoted: true }; + return undefined; + } + private handleKeyDown(event: KeyboardEvent) { if (this.completions.length) { if (event.key === "ArrowDown") { @@ -148,6 +169,14 @@ export class PromptEditor extends LitElement { return; } } + if (event.key === "Tab") { + const trigger = this.currentTrigger(); + if (trigger?.kind === "file") { + event.preventDefault(); + void this.refreshCompletions(); + return; + } + } if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); this.send(this.canSteer || this.isCompacting ? "followUp" : undefined); @@ -155,10 +184,13 @@ export class PromptEditor extends LitElement { } private pick(item: CompletionItem) { - const suffix = item.kind === "file" && item.insertText.endsWith("/") ? "" : " "; - this.draft = `${this.draft.slice(0, item.replaceFrom)}${item.insertText}${suffix}${this.draft.slice(item.replaceTo)}`; + const suffix = item.kind === "file" && (item.insertText.endsWith("/") || item.cursorOffset !== undefined) ? "" : " "; + const cursor = item.replaceFrom + (item.cursorOffset ?? item.insertText.length) + suffix.length; + const after = item.insertText.endsWith("\"") && this.draft.slice(item.replaceTo).startsWith("\"") ? this.draft.slice(item.replaceTo + 1) : this.draft.slice(item.replaceTo); + this.draft = `${this.draft.slice(0, item.replaceFrom)}${item.insertText}${suffix}${after}`; if (this.sessionId !== undefined && this.sessionId !== "") saveDraft(this.sessionId, this.draft); this.completions = []; + void this.updateComplete.then(() => this.textarea?.setSelectionRange(cursor, cursor)); } private send(streamingBehavior?: "steer" | "followUp") { @@ -173,6 +205,12 @@ export class PromptEditor extends LitElement { static override styles = promptEditorStyles; } +function fileInsertText(path: string, pathMode: boolean, quoted: boolean): string { + const prefix = pathMode ? "" : "@"; + if (!quoted && !path.includes(" ")) return `${prefix}${path}`; + return `${prefix}\"${path}\"`; +} + function emptySlashCommands(): SlashCommand[] { return []; } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 369641b..8479836 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -21,6 +21,7 @@ export interface CompletionItem { insertText: string; detail: string; description?: string; + cursorOffset?: number; } export const appStyles = css` diff --git a/src/client/src/inputModes.ts b/src/client/src/inputModes.ts index 26c0d18..680683b 100644 --- a/src/client/src/inputModes.ts +++ b/src/client/src/inputModes.ts @@ -25,5 +25,9 @@ function isFileCompletionContext(draft: string): boolean { const token = currentToken(draft); if (token.startsWith("@")) return true; const tokenStart = draft.length - token.length; - return draft.slice(0, tokenStart).endsWith("@ "); + 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("@ "); }