import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { api, type FileSuggestion, type SlashCommand } from "../api"; import { promptEditorStyles, type CompletionItem } from "./shared"; import "./AutocompleteMenu"; @customElement("prompt-editor") export class PromptEditor extends LitElement { @property({ type: Boolean }) disabled = false; @property() sessionId?: string; @property() cwd?: string; @property({ attribute: false }) onSend?: (text: string) => void; @property({ attribute: false }) onCloseSession?: () => void; @state() private draft = ""; @state() private completions: CompletionItem[] = []; @state() private selectedIndex = 0; private requestVersion = 0; render() { return html` `; } private updateDraft(value: string) { this.draft = value; void this.refreshCompletions(); } private async refreshCompletions() { const trigger = this.currentTrigger(); const version = ++this.requestVersion; this.selectedIndex = 0; if (!trigger) { this.completions = []; return; } if (trigger.kind === "command" && this.sessionId) { const commands = await api.commands(this.sessionId).catch(() => [] as SlashCommand[]); if (version !== this.requestVersion) return; this.completions = commands .filter((command) => command.name.toLowerCase().includes(trigger.query.toLowerCase())) .slice(0, 12) .map((command) => ({ kind: "command", replaceFrom: trigger.from, replaceTo: this.draft.length, insertText: `/${command.name}`, detail: command.source, description: command.description })); } else if (trigger.kind === "file" && this.cwd) { const files = await api.files(this.cwd, trigger.query, trigger.fileKind).catch(() => [] as FileSuggestion[]); if (version !== this.requestVersion) return; this.completions = files .slice(0, 12) .map((file) => ({ kind: "file", replaceFrom: trigger.from, replaceTo: this.draft.length, insertText: `@${file.path}`, detail: file.kind })); } } private currentTrigger(): { kind: "command" | "file"; query: string; from: number; fileKind?: FileSuggestion["kind"] } | undefined { const beforeCursor = this.draft; if (beforeCursor.endsWith("@ ")) return { kind: "file", query: "", from: beforeCursor.length - 2, fileKind: "untracked" }; const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1; const token = beforeCursor.slice(tokenStart); if (token.startsWith("/")) return { kind: "command", query: token.slice(1), from: tokenStart }; if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart }; return undefined; } private handleKeyDown(event: KeyboardEvent) { if (this.completions.length) { if (event.key === "ArrowDown") { event.preventDefault(); this.selectedIndex = (this.selectedIndex + 1) % this.completions.length; return; } if (event.key === "ArrowUp") { event.preventDefault(); this.selectedIndex = (this.selectedIndex - 1 + this.completions.length) % this.completions.length; return; } if (event.key === "Tab" || event.key === "Enter") { event.preventDefault(); this.pick(this.completions[this.selectedIndex]); return; } if (event.key === "Escape") { event.preventDefault(); this.completions = []; return; } } if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); this.send(); } } private pick(item: CompletionItem) { this.draft = `${this.draft.slice(0, item.replaceFrom)}${item.insertText} ${this.draft.slice(item.replaceTo)}`; this.completions = []; } private send() { const text = this.draft.trim(); if (!text || this.disabled) return; this.draft = ""; this.completions = []; this.onSend?.(text); } static styles = promptEditorStyles; }