import { CustomEditor, type ExtensionAPI, type ExtensionUIContext, type KeybindingsManager, } from "@earendil-works/pi-coding-agent"; import type { AutocompleteItem, AutocompleteProvider, EditorTheme, TUI, } from "@earendil-works/pi-tui"; import { buildInjectedSkillMessage } from "./injected-skill-message.js"; import { type SkillrefsCustomMessage, SkillrefsCustomMessages, } from "./models/SkillrefsCustomMessage.js"; import { registerPiFzfpCompatibility, type WrapAutocomplete, } from "./pi-fzfp-compat.js"; import { renderSkillrefsMessage } from "./render-skillrefs-message.js"; import { buildSkillAutocompleteItems, collectDiscoveredSkills, createMentionAutocompleteProvider, findMentionTokenAtCursor, } from "./utils.js"; type Cursor = { line: number; col: number; }; type SkillRefsEditorTarget = { setAutocompleteProvider(provider: AutocompleteProvider): void; handleInput(data: string): void; isShowingAutocomplete(): boolean; getLines(): string[]; getCursor(): Cursor; }; type SkillRefsSessionContext = { ui: Pick; }; type SkillRefsRecord = Record; type SessionEditorComponentFactory = Parameters[0]; const SKILL_REFS_EDITOR_ENHANCED = Symbol("skillrefs-editor-enhanced"); function isPrintableInput(data: string): boolean { return data.length === 1 && data.charCodeAt(0) >= 32; } function hasMethod(value: SkillRefsRecord, name: string): boolean { return typeof Reflect.get(value, name) === "function"; } function isObject(value: unknown): value is SkillRefsRecord { return typeof value === "object" && value !== null; } function isSkillRefsEditorTarget(value: unknown): value is SkillRefsEditorTarget { if (!isObject(value)) { return false; } return hasMethod(value, "setAutocompleteProvider") && hasMethod(value, "handleInput") && hasMethod(value, "isShowingAutocomplete") && hasMethod(value, "getLines") && hasMethod(value, "getCursor"); } function triggerAutocomplete(editor: SkillRefsEditorTarget): void { const method = Reflect.get(editor, "tryTriggerAutocomplete"); if (typeof method !== "function") { return; } Reflect.apply(method, editor, []); } function updateAutocomplete(editor: SkillRefsEditorTarget, data: string): void { if (editor.isShowingAutocomplete()) { return; } if (!isPrintableInput(data)) { return; } const lines = editor.getLines(); const cursor = editor.getCursor(); const line = lines[cursor.line] || ""; const mention = findMentionTokenAtCursor(line, cursor.col); if (!mention) { return; } triggerAutocomplete(editor); } class SkillRefsEditor extends CustomEditor { private readonly getSkillItems: () => AutocompleteItem[]; private readonly wrapAutocomplete: WrapAutocomplete | undefined; constructor( tui: TUI, theme: EditorTheme, options: { keybindings: KeybindingsManager; getSkillItems: () => AutocompleteItem[]; wrapAutocomplete: WrapAutocomplete | undefined; }, ) { super(tui, theme, options.keybindings); this.getSkillItems = options.getSkillItems; this.wrapAutocomplete = options.wrapAutocomplete; } override setAutocompleteProvider(provider: AutocompleteProvider): void { let nextProvider = createMentionAutocompleteProvider(provider, this.getSkillItems); if (this.wrapAutocomplete) { nextProvider = this.wrapAutocomplete(nextProvider); } super.setAutocompleteProvider(nextProvider); } override handleInput(data: string): void { super.handleInput(data); updateAutocomplete(this, data); } } function isEnhanced(editor: SkillRefsRecord): boolean { return Reflect.get(editor, SKILL_REFS_EDITOR_ENHANCED) === true; } function markEnhanced(editor: SkillRefsRecord): void { Reflect.set(editor, SKILL_REFS_EDITOR_ENHANCED, true); } function enhanceEditorWithSkillRefs( editor: TEditor, getSkillItems: () => AutocompleteItem[], wrapAutocomplete: WrapAutocomplete | undefined, ): TEditor { if (isEnhanced(editor)) { return editor; } markEnhanced(editor); const baseSetAutocompleteProvider = editor.setAutocompleteProvider.bind(editor); editor.setAutocompleteProvider = (provider: AutocompleteProvider) => { let nextProvider = createMentionAutocompleteProvider(provider, getSkillItems); if (wrapAutocomplete) { nextProvider = wrapAutocomplete(nextProvider); } baseSetAutocompleteProvider(nextProvider); }; const baseHandleInput = editor.handleInput.bind(editor); editor.handleInput = (data: string) => { baseHandleInput(data); updateAutocomplete(editor, data); }; return editor; } function createEditorFactory( previousFactory: SessionEditorComponentFactory | undefined, getSkillItems: () => AutocompleteItem[], wrapAutocomplete: WrapAutocomplete | undefined, ): SessionEditorComponentFactory { return (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => { const previousEditor = previousFactory?.(tui, theme, keybindings); if (isSkillRefsEditorTarget(previousEditor)) { return enhanceEditorWithSkillRefs(previousEditor, getSkillItems, wrapAutocomplete); } return new SkillRefsEditor(tui, theme, { keybindings, getSkillItems, wrapAutocomplete }); }; } function installEditor( ctx: SkillRefsSessionContext, getSkillItems: () => AutocompleteItem[], wrapAutocomplete: WrapAutocomplete | undefined, ): void { const previousFactory = ctx.ui.getEditorComponent(); ctx.ui.setEditorComponent(createEditorFactory(previousFactory, getSkillItems, wrapAutocomplete)); } export default function piSkillrefs(pi: ExtensionAPI): void { let skillMap = new Map(); let skillItems: AutocompleteItem[] = []; let wrapAutocomplete: WrapAutocomplete | undefined; pi.registerMessageRenderer(SkillrefsCustomMessages.type, renderSkillrefsMessage); registerPiFzfpCompatibility(pi, (nextWrapAutocomplete) => { wrapAutocomplete = nextWrapAutocomplete; }); function refreshSkillMap(): void { skillMap = collectDiscoveredSkills(pi.getCommands()); skillItems = buildSkillAutocompleteItems(skillMap); } pi.on("session_start", (_event, ctx) => { refreshSkillMap(); if (!ctx.hasUI) { return; } installEditor(ctx, () => skillItems, wrapAutocomplete); }); pi.on("resources_discover", () => { refreshSkillMap(); }); pi.on("input", () => { return { action: "continue" }; }); pi.on("context", (event) => { return { messages: event.messages.map((message) => SkillrefsCustomMessages.restoreContent(message)), }; }); pi.on("before_agent_start", async (event, ctx) => { const message = await buildInjectedSkillMessage(event.prompt, skillMap, ctx.sessionManager); if (!message) { return undefined; } const customMessage: SkillrefsCustomMessage = SkillrefsCustomMessages.create( message.content, message.skills, ); return { message: customMessage }; }); }