import { DynamicBorder } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import { Container, fuzzyFilter, Input, Spacer, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { Component } from "@earendil-works/pi-tui"; import { connect } from "@nativepi/extension-api/host"; import { titleGeneratorProtocol, type TitleGeneratorState } from "../types.ts"; type TitleModelSetting = string; type TitleModel = NonNullable; type ModelKey = Pick; type PendingTitle = { sessionFile: string; prompt: string; modelSetting: TitleModelSetting; }; const TITLE_GENERATOR_ACTIVE = "active"; const TITLE_GENERATOR_MAX_LENGTH = 80; const TITLE_MODEL_ENTRY = "nativepi-title-generator"; const MAX_PROMPT_INPUT_LENGTH = 2000; const TITLE_TIMEOUT_MS = 30_000; const TITLE_REQUEST_SYSTEM = "You generate concise coding-chat titles. Reply with the title only."; const titleModelBySession = new Map(); let pendingTitleModel: TitleModelSetting = TITLE_GENERATOR_ACTIVE; function modelKey(model: { provider: string; id: string }): string { return `${model.provider}/${model.id}`; } function modelKeyParts(key: string): ModelKey | undefined { if (key === TITLE_GENERATOR_ACTIVE) return undefined; const separator = key.indexOf("/"); if (separator <= 0 || separator === key.length - 1) return undefined; return { provider: key.slice(0, separator), id: key.slice(separator + 1) }; } function displayPromptText(raw: string): string { const text = raw.trim(); const skill = /^]*>[\s\S]*?<\/skill>(?:\s*([\s\S]+))?$/.exec(text); if (skill) return skill[2]?.trim() || skill[1] || ""; const skillOpen = /^[\s\S]*?<\/file>\s*([\s\S]*)$/.exec(text); if (file) { const request = file[2]?.trim(); if (request) return request; return file[1]?.split(/[\\/]/).at(-1) || ""; } const fileOpen = /^ part.trim()) .find(Boolean); if (!line) return null; const cleaned = line .replace(/^title\s*:\s*/i, "") .replace(/^["'`“”]+|["'`“”]+$/g, "") .replace(/\s+/g, " ") .trim(); if (!cleaned) return null; const characters = [...cleaned]; if (characters.length <= TITLE_GENERATOR_MAX_LENGTH) return cleaned; return `${characters.slice(0, TITLE_GENERATOR_MAX_LENGTH - 1).join("").trimEnd()}…`; } function hasUserMessage(context: ExtensionContext): boolean { return context.sessionManager.getEntries().some((entry) => entry.type === "message" && entry.message.role === "user"); } function persistedTitleModel(context: ExtensionContext): TitleModelSetting | undefined { const entries = context.sessionManager.getEntries(); for (let index = entries.length - 1; index >= 0; index -= 1) { const entry = entries[index]; if (entry?.type !== "custom" || entry.customType !== TITLE_MODEL_ENTRY) continue; const data = entry.data; if (typeof data !== "object" || data === null || Array.isArray(data) || !("modelSetting" in data)) continue; const modelSetting = data.modelSetting; if (typeof modelSetting === "string" && modelSetting.trim()) return modelSetting; } return undefined; } function titleModelSettingFor(context: ExtensionContext): TitleModelSetting { const sessionFile = context.sessionManager.getSessionFile(); return (sessionFile ? titleModelBySession.get(sessionFile) : undefined) ?? persistedTitleModel(context) ?? pendingTitleModel; } function selectedModel(context: ExtensionContext, setting: TitleModelSetting): TitleModel | undefined { if (setting === TITLE_GENERATOR_ACTIVE) return context.model ?? undefined; const parts = modelKeyParts(setting); if (!parts) return undefined; return context.modelRegistry.find(parts.provider, parts.id) ?? undefined; } function isTextPart(part: unknown): part is { type: "text"; text: string } { if (typeof part !== "object" || part === null || !("type" in part) || !("text" in part)) return false; return part.type === "text" && typeof part.text === "string"; } function assistantText(message: unknown): string { if (typeof message !== "object" || message === null || !("content" in message) || !Array.isArray(message.content)) return ""; return message.content.filter(isTextPart).map((part) => part.text).join(""); } async function generateTitle(context: ExtensionContext, prompt: string, model: TitleModel, controller: AbortController): Promise { const provider = context.modelRegistry.getProvider(model.provider); if (!provider) return null; const resolvedAuth = await context.modelRegistry.getApiKeyAndHeaders(model); if (!resolvedAuth.ok) return null; // Reasoning models can spend the small title budget before emitting text. const stream = provider.streamSimple( model, { systemPrompt: TITLE_REQUEST_SYSTEM, messages: [{ role: "user", content: prompt, timestamp: Date.now() }], }, { apiKey: resolvedAuth.apiKey, headers: resolvedAuth.headers, env: resolvedAuth.env, signal: controller.signal, maxTokens: model.reasoning ? 1024 : 64, maxRetries: 2, }, ); const result = await stream.result(); return normalizeGeneratedTitle(assistantText(result)); } type TitleChoice = { key: string; name: string; provider: string }; function modelChoices(context: ExtensionContext): TitleChoice[] { const choices = new Map([ [TITLE_GENERATOR_ACTIVE, { key: TITLE_GENERATOR_ACTIVE, name: "Use the chat model", provider: "" }], ]); for (const model of context.modelRegistry.getAvailable()) { const key = modelKey(model); if (choices.has(key)) continue; choices.set(key, { key, name: model.name || model.id, provider: context.modelRegistry.getProviderDisplayName(model.provider), }); } return [...choices.values()]; } function choiceLabel(choice: TitleChoice): string { return choice.provider ? `${choice.name} · ${choice.provider}` : choice.name; } function state(context: ExtensionContext): TitleGeneratorState { return { modelSetting: titleModelSettingFor(context), models: modelChoices(context).map((choice) => ({ key: choice.key, label: choiceLabel(choice) })), }; } function setTitleModel(pi: ExtensionAPI, context: ExtensionContext, setting: string): boolean { if (!modelChoices(context).some((choice) => choice.key === setting)) { context.ui.notify("That model is not available in Pi's catalog.", "warning"); return false; } const sessionFile = context.sessionManager.getSessionFile(); if (sessionFile) { titleModelBySession.set(sessionFile, setting); pi.appendEntry(TITLE_MODEL_ENTRY, { modelSetting: setting }); } else { pendingTitleModel = setting; } return true; } /** * The slices of Pi's TUI the picker actually uses. * * `TUI` and `KeybindingsManager` carry private fields, and this package resolves * `@earendil-works/pi-tui` to a different copy than Pi's own, so naming those * classes here would compare two nominally distinct types. */ type PickerHost = { requestRender(): void }; type PickerBinding = "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel"; type PickerKeys = { matches(data: string, binding: PickerBinding): boolean; getKeys(binding: PickerBinding): string[]; }; const PICKER_MAX_VISIBLE = 10; const PICKER_NAME_COLUMN = 34; /** Stands in for the provider column on the one choice that has no provider of its own. */ const PICKER_ACTIVE_DETAIL = "follows whatever this chat runs on"; /** The visible slice of the catalog, laid out to whatever width the terminal currently is. */ class PickerList implements Component { items: TitleChoice[] = []; selectedIndex = 0; constructor( private readonly theme: Theme, private readonly current: string, ) {} invalidate(): void {} render(width: number): string[] { const theme = this.theme; if (this.items.length === 0) return [` ${theme.fg("muted", "No matching models")}`]; const available = Math.max(16, width - 4); const start = Math.max( 0, Math.min(this.selectedIndex - Math.floor(PICKER_MAX_VISIBLE / 2), this.items.length - PICKER_MAX_VISIBLE), ); const end = Math.min(start + PICKER_MAX_VISIBLE, this.items.length); const visible = this.items.slice(start, end); const widest = visible.reduce((value, choice) => Math.max(value, visibleWidth(choice.name)), 0); const column = Math.max(8, Math.min(widest + 2, PICKER_NAME_COLUMN, Math.round(available * 0.6))); const lines = visible.map((choice, offset) => { const selected = start + offset === this.selectedIndex; const name = truncateToWidth(choice.name, column - 1, "…"); const padded = name + " ".repeat(Math.max(1, column - visibleWidth(name))); const mark = choice.key === this.current ? " ✓" : ""; const detail = truncateToWidth( choice.provider || PICKER_ACTIVE_DETAIL, Math.max(0, available - column - mark.length), "…", ); return ( ` ${selected ? theme.fg("accent", `→ ${padded}`) : ` ${padded}`}` + theme.fg("muted", detail) + theme.fg("success", mark) ); }); if (this.items.length > PICKER_MAX_VISIBLE) { lines.push(` ${theme.fg("dim", ` ${this.selectedIndex + 1}/${this.items.length}`)}`); } return lines; } } /** * The `/title-model` picker. * * Pi's catalog runs to hundreds of models, so this is search-first: the list * narrows as you type rather than asking you to scroll past everything you are * logged in to. */ class TitleModelPicker extends Container { private readonly search = new Input(); private readonly list: PickerList; private searchFocused = false; get focused(): boolean { return this.searchFocused; } set focused(value: boolean) { this.searchFocused = value; this.search.focused = value; } constructor( private readonly tui: PickerHost, private readonly theme: Theme, private readonly keybindings: PickerKeys, private readonly choices: TitleChoice[], private readonly current: string, private readonly done: (key: string | undefined) => void, ) { super(); this.list = new PickerList(theme, current); this.list.items = choices; this.list.selectedIndex = Math.max(0, choices.findIndex((choice) => choice.key === current)); const hint = (key: string, description: string) => theme.fg("dim", key) + theme.fg("muted", ` ${description}`); this.addChild(new DynamicBorder((line) => theme.fg("border", line))); this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("accent", theme.bold("Title model")), 1, 0)); this.addChild(new Text(theme.fg("dim", "Names a new chat once, from your first message"), 1, 0)); this.addChild(new Spacer(1)); this.addChild(this.search); this.addChild(new Spacer(1)); this.addChild(this.list); this.addChild(new Spacer(1)); this.addChild( new Text( [ hint("↑↓", "navigate"), hint(keybindings.getKeys("tui.select.confirm").join("/"), "select"), hint(keybindings.getKeys("tui.select.cancel").join("/"), "cancel"), ].join(" "), 1, 0, ), ); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder((line) => theme.fg("border", line))); } handleInput(data: string): void { const keys = this.keybindings; const list = this.list; if (keys.matches(data, "tui.select.cancel")) { this.done(undefined); return; } if (keys.matches(data, "tui.select.confirm")) { const choice = list.items[list.selectedIndex]; if (choice) this.done(choice.key); return; } if (list.items.length > 0 && keys.matches(data, "tui.select.up")) { list.selectedIndex = list.selectedIndex === 0 ? list.items.length - 1 : list.selectedIndex - 1; } else if (list.items.length > 0 && keys.matches(data, "tui.select.down")) { list.selectedIndex = list.selectedIndex === list.items.length - 1 ? 0 : list.selectedIndex + 1; } else { this.search.handleInput(data); const query = this.search.getValue(); list.items = query ? fuzzyFilter(this.choices, query, (choice) => `${choice.name} ${choice.provider} ${choice.key}`) : this.choices; list.selectedIndex = Math.min(list.selectedIndex, Math.max(0, list.items.length - 1)); } this.tui.requestRender(); } } export default function titleGeneratorExtension(pi: ExtensionAPI): void { let activeSessionFile: string | undefined; let latest: ExtensionContext | undefined; let pendingTitle: PendingTitle | undefined; let titleAbort: AbortController | undefined; let generationInFlight = false; const ui = connect("@nativepi/title-generator", titleGeneratorProtocol, { state: () => { if (!latest) throw new Error("No active Pi session."); return state(latest); }, set: ({ modelSetting }) => { if (!latest) throw new Error("No active Pi session."); if (!setTitleModel(pi, latest, modelSetting)) { throw new Error("That model is not available in Pi's catalog."); } const next = state(latest); ui.emit("changed", next); return next; }, }); const emitState = (context: ExtensionContext) => ui.emit("changed", state(context)); pi.on("session_start", (_event, context) => { latest = context; titleAbort?.abort(); titleAbort = undefined; pendingTitle = undefined; generationInFlight = false; activeSessionFile = context.sessionManager.getSessionFile(); emitState(context); }); pi.on("model_select", (_event, context) => { latest = context; emitState(context); }); pi.on("before_agent_start", (event, context) => { latest = context; const sessionFile = context.sessionManager.getSessionFile(); if (!sessionFile) return; if (pendingTitle || generationInFlight || context.sessionManager.getSessionName() || hasUserMessage(context)) return; const prompt = titlePrompt(event.prompt); if (!prompt) return; pendingTitle = { sessionFile, prompt, modelSetting: titleModelSettingFor(context) }; }); pi.on("agent_settled", (_event, context) => { latest = context; const candidate = pendingTitle; if (!candidate || candidate.sessionFile !== activeSessionFile || generationInFlight) return; pendingTitle = undefined; generationInFlight = true; const controller = new AbortController(); titleAbort = controller; const timeout = setTimeout(() => controller.abort(), TITLE_TIMEOUT_MS); void (async () => { try { const model = selectedModel(context, candidate.modelSetting); if (!model) return; const title = await generateTitle(context, candidate.prompt, model, controller); if (!title || activeSessionFile !== candidate.sessionFile || pi.getSessionName()) return; pi.setSessionName(title); } catch { // Title generation is best effort; Pi's deterministic first-message fallback remains visible. } finally { clearTimeout(timeout); if (titleAbort === controller) titleAbort = undefined; generationInFlight = false; } })(); }); pi.on("session_shutdown", () => { titleAbort?.abort(); titleAbort = undefined; pendingTitle = undefined; generationInFlight = false; activeSessionFile = undefined; latest = undefined; }); pi.registerCommand("title-model", { description: "Choose the model Pi uses for automatic first-message titles", getArgumentCompletions: (prefix) => latest ? modelChoices(latest) .filter((choice) => choice.key.startsWith(prefix)) .map((choice) => ({ value: choice.key, label: choiceLabel(choice) })) : null, handler: async (args, context) => { latest = context; const choices = modelChoices(context); const setting = args.trim() || (await context.ui.custom( (tui, theme, keybindings, done) => new TitleModelPicker(tui, theme, keybindings, choices, titleModelSettingFor(context), done), )); if (!setting || !setTitleModel(pi, context, setting)) return; const chosen = choices.find((choice) => choice.key === setting); context.ui.notify(`Title model: ${chosen ? choiceLabel(chosen) : setting}`, "info"); emitState(context); }, }); }