Archived
Add prompt autocomplete and session status
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
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`
|
||||
<footer>
|
||||
<div class="editor-wrap">
|
||||
<textarea
|
||||
.value=${this.draft}
|
||||
?disabled=${this.disabled}
|
||||
@input=${(event: Event) => this.updateDraft((event.target as HTMLTextAreaElement).value)}
|
||||
@keydown=${(event: KeyboardEvent) => this.handleKeyDown(event)}
|
||||
placeholder="Message pi... Use / for commands, @ for files"
|
||||
></textarea>
|
||||
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => this.pick(item)}></autocomplete-menu>
|
||||
</div>
|
||||
<button ?disabled=${this.disabled} @click=${this.send}>Send</button>
|
||||
<button ?disabled=${this.disabled} @click=${() => this.onCloseSession?.()}>Close</button>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user