import { existsSync } from "node:fs"; import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, SessionManager, UserMessageSelectorComponent, } from "@earendil-works/pi-coding-agent"; import type { AutocompleteItem } from "@earendil-works/pi-tui"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { closeDebug, dbg, span } from "./src/debug.ts"; import { resolveSetting, type SettingDeclaration, } from "./src/pi-ext-settings.ts"; const ENTRY_TYPE = "notes-state"; const WIDGET_KEY = "notes"; const NOTES_ICON = "󰎚"; const WIDGET_DEFAULT_WIDTH = 80; const NEW_SESSION_NOTES = Symbol.for("@bugabinga/pi-ext-notes/new-session"); const NOTE_LAUNCH = Symbol.for("@bugabinga/pi-ext-notes/launch"); type Shortcut = Parameters[0]; function parseShortcut(raw: unknown): Shortcut { if (typeof raw === "string" && raw.trim()) return raw.trim() as Shortcut; throw new Error("must be a non-empty string"); } const shortcutSetting = ( name: string, env: string, fallback: Shortcut, ): SettingDeclaration => ({ key: `notes.${name}`, env, default: fallback, parse: parseShortcut, }); const SHORTCUT_SETTINGS = { toggle: shortcutSetting( "toggleShortcut", "PI_NOTES_TOGGLE_SHORTCUT", "alt+n", ), addFromEditor: shortcutSetting( "addFromEditorShortcut", "PI_NOTES_ADD_FROM_EDITOR_SHORTCUT", "ctrl+enter", ), sendPicker: shortcutSetting( "sendPickerShortcut", "PI_NOTES_SEND_PICKER_SHORTCUT", "ctrl+alt+enter", ), }; // Shortcuts register at load, before any session: user settings only. function readShortcuts(pi: ExtensionAPI) { const errors: string[] = []; const ctx = { cwd: process.cwd(), isProjectTrusted: () => false }; const read = (decl: SettingDeclaration) => { const setting = resolveSetting(pi, ctx, decl); if (setting.ok) return setting.value; errors.push(setting.error); return decl.default; }; const { toggle, addFromEditor, sendPicker } = SHORTCUT_SETTINGS; const shortcuts = { toggle: read(toggle), addFromEditor: read(addFromEditor), sendPicker: read(sendPicker), }; const values = Object.values(shortcuts); if (new Set(values).size === values.length) return { shortcuts, errors }; errors.push(`Duplicate notes shortcuts: ${values.join(", ")}`); return { shortcuts: { toggle: toggle.default, addFromEditor: addFromEditor.default, sendPicker: sendPicker.default, }, errors, }; } type Note = { id: number; key: string; text: string }; type NotesEvent = | { op: "add"; id: number; key?: string; text: string; createdAt: number } | { op: "edit"; id: number; text: string; updatedAt: number } | { op: "remove"; id: number; removedAt: number } | { op: "clear"; clearedAt: number }; type SessionEntryLike = { id?: unknown; type?: unknown; customType?: unknown; data?: unknown; message?: unknown; }; type NotesState = { notes: Map; nextId: number; }; type NoteLaunchKind = "clone" | "fork" | "new"; type DestinationReceiver = { instanceToken: string; consume(): boolean; fail(message: string): void; revoke(): void; }; type ReplacementContext = ExtensionCommandContext & { sendUserMessage( content: string, options?: { expandPromptTemplates?: boolean }, ): Promise; }; type NoteLaunch = { token: string; sourceInstanceToken: string; sourceSessionId: string; kind: NoteLaunchKind; note: Note; sourceLeafId?: string; sourceSessionFile?: string; targetSessionFile?: string; shutdownSeen: boolean; destinationClaimed: boolean; replacementContext?: ReplacementContext; receiver?: DestinationReceiver; }; function createState(): NotesState { return { notes: new Map(), nextId: 1 }; } function isPositiveInteger(value: unknown): value is number { return Number.isInteger(value) && typeof value === "number" && value > 0; } function isNonEmptyText(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } function parseEvent(data: unknown): NotesEvent | undefined { if (!data || typeof data !== "object") return undefined; const event = data as Record; if ( event.op === "add" && isPositiveInteger(event.id) && isNonEmptyText(event.text) && typeof event.createdAt === "number" ) { return { op: "add", id: event.id, key: isNonEmptyText(event.key) ? event.key.trim() : undefined, text: event.text.trim(), createdAt: event.createdAt, }; } if ( event.op === "edit" && isPositiveInteger(event.id) && isNonEmptyText(event.text) && typeof event.updatedAt === "number" ) { return { op: "edit", id: event.id, text: event.text.trim(), updatedAt: event.updatedAt, }; } if ( event.op === "remove" && isPositiveInteger(event.id) && typeof event.removedAt === "number" ) { return { op: "remove", id: event.id, removedAt: event.removedAt }; } if (event.op === "clear" && typeof event.clearedAt === "number") { return { op: "clear", clearedAt: event.clearedAt }; } return undefined; } function applyEvent(state: NotesState, event: NotesEvent): void { if (event.op === "add") { state.notes.set(event.id, { id: event.id, key: event.key ?? `legacy:${event.id}:${event.createdAt}`, text: event.text, }); state.nextId = Math.max(state.nextId, event.id + 1); return; } if (event.op === "edit") { const note = state.notes.get(event.id); if (!note) return; state.notes.set(event.id, { ...note, text: event.text }); state.nextId = Math.max(state.nextId, event.id + 1); return; } if (event.op === "remove") { if (!state.notes.has(event.id)) return; state.notes.delete(event.id); state.nextId = Math.max(state.nextId, event.id + 1); return; } state.notes.clear(); } function replayNotes(entries: SessionEntryLike[]): NotesState { const state = createState(); for (const entry of entries) { if (entry.type !== "custom" || entry.customType !== ENTRY_TYPE) continue; const event = parseEvent(entry.data); if (event) applyEvent(state, event); } return state; } function pendingNotes(state: NotesState): Note[] { return [...state.notes.values()].sort((a, b) => a.id - b.id); } function noteWithKey(state: NotesState, key: string): Note | undefined { return pendingNotes(state).find((note) => note.key === key); } function replaceState(target: NotesState, source: NotesState): void { target.notes = source.notes; target.nextId = source.nextId; } function newSessionHandoffs(): Map { const existing = Reflect.get(globalThis, NEW_SESSION_NOTES); if (existing instanceof Map) return existing; const handoffs = new Map(); Reflect.set(globalThis, NEW_SESSION_NOTES, handoffs); return handoffs; } function stashNewSessionNotes( targetSessionFile: string | undefined, notes: Note[], ): void { newSessionHandoffs().set(targetSessionFile, structuredClone(notes)); } function takeNewSessionNotes(targetSessionFile?: string): Note[] { const handoffs = newSessionHandoffs(); const notes = handoffs.get(targetSessionFile) ?? []; handoffs.delete(targetSessionFile); if (handoffs.size === 0) Reflect.deleteProperty(globalThis, NEW_SESSION_NOTES); return notes; } function activeLaunch(): NoteLaunch | undefined { const launch = Reflect.get(globalThis, NOTE_LAUNCH); return launch && typeof launch === "object" ? (launch as NoteLaunch) : undefined; } function startLaunch(launch: NoteLaunch): boolean { if (activeLaunch()) return false; Reflect.set(globalThis, NOTE_LAUNCH, launch); return true; } function finishLaunch(launch: NoteLaunch): void { if (activeLaunch()?.token === launch.token) { Reflect.deleteProperty(globalThis, NOTE_LAUNCH); } } function launchReason(kind: NoteLaunchKind): "fork" | "new" { return kind === "new" ? "new" : "fork"; } function appendEvent( pi: ExtensionAPI, state: NotesState, event: NotesEvent, ): void { pi.appendEntry(ENTRY_TYPE, event); applyEvent(state, event); } function notify( ctx: ExtensionContext, message: string, type: "info" | "warning" | "error" = "info", ): void { if (ctx.hasUI) ctx.ui.notify(message, type); } function preview(text: string, max = 72): string { const oneLine = text .replace(/[\x00-\x1F\x7F]+/g, " ") .replace(/ +/g, " ") .trim(); return oneLine.length > max ? `${oneLine.slice(0, Math.max(0, max - 1))}…` : oneLine; } function fitVisible(text: string, width: number): string { return truncateToWidth(text, Math.max(0, width), "…"); } function wrapVisibleWords(text: string, width: number): string[] { if (width <= 0) return [""]; const words = text.replace(/\s+/g, " ").trim().split(" ").filter(Boolean); if (words.length === 0) return [""]; const lines: string[] = []; let line = ""; for (const word of words) { if (visibleWidth(word) > width) { if (line) { lines.push(line); line = ""; } let rest = word; while (visibleWidth(rest) > width) { const part = truncateToWidth(rest, width, ""); if (!part) break; lines.push(part); rest = rest.slice(part.length); } line = rest; continue; } const candidate = line ? `${line} ${word}` : word; if (visibleWidth(candidate) <= width) line = candidate; else { lines.push(line); line = word; } } if (line) lines.push(line); return lines; } function iconWrap(rows: string[], width: number): string[] { const safeWidth = Math.max(1, width); const firstPrefix = `${NOTES_ICON} `; const nextPrefix = " "; const lines: string[] = []; for (const row of rows) { const prefix = lines.length === 0 ? firstPrefix : nextPrefix; const textWidth = Math.max(1, safeWidth - visibleWidth(prefix)); for (const part of wrapVisibleWords(row, textWidth)) { lines.push(`${lines.length === 0 ? firstPrefix : nextPrefix}${part}`); } } return lines; } function compactWidgetLine( state: NotesState, width = WIDGET_DEFAULT_WIDTH, ): string { const notes = pendingNotes(state); const last = notes.at(-1); return fitVisible( `${NOTES_ICON} ${notes.length} notes · alt+n expand · last: ${preview( last?.text ?? "", )}`, width, ); } function expandedWidgetLines( state: NotesState, width = WIDGET_DEFAULT_WIDTH, ): string[] { const notes = pendingNotes(state); const lines = iconWrap([`notes · ${notes.length}`], width); for (const note of notes) { const prefix = ` ${note.id} `; const textWidth = Math.max(1, width - visibleWidth(prefix)); for (const [index, part] of wrapVisibleWords( note.text, textWidth, ).entries()) { lines.push(`${index === 0 ? prefix : " "}${part}`); } } return lines; } function widgetLines( state: NotesState, expanded: boolean, width = WIDGET_DEFAULT_WIDTH, ): string[] | undefined { if (state.notes.size === 0) return undefined; return expanded ? expandedWidgetLines(state, width) : [compactWidgetLine(state, width)]; } function splitCommand(args: string): { command: string; rest: string } { const trimmed = args.trim(); const space = trimmed.indexOf(" "); if (space === -1) return { command: trimmed, rest: "" }; return { command: trimmed.slice(0, space), rest: trimmed.slice(space + 1).trim(), }; } function parseId(text: string): number | undefined { const value = Number(text.trim()); return isPositiveInteger(value) ? value : undefined; } function requireNote( ctx: ExtensionContext, state: NotesState, idText: string, ): Note | undefined { const id = parseId(idText); if (!id || !state.notes.has(id)) { notify(ctx, `Unknown note id: ${idText.trim() || "?"}`, "error"); return undefined; } return state.notes.get(id); } function messageText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .filter( (part): part is { type: "text"; text: string } => part !== null && typeof part === "object" && Reflect.get(part, "type") === "text" && typeof Reflect.get(part, "text") === "string", ) .map((part) => part.text) .join(""); } function userMessagesForForking( entries: SessionEntryLike[], ): Array<{ entryId: string; text: string }> { const messages: Array<{ entryId: string; text: string }> = []; for (const entry of entries) { if (entry.type !== "message" || typeof entry.id !== "string") continue; if (!entry.message || typeof entry.message !== "object") continue; if (Reflect.get(entry.message, "role") !== "user") continue; const text = messageText(Reflect.get(entry.message, "content")); if (text) messages.push({ entryId: entry.id, text }); } return messages; } async function selectForkEntry( ctx: ExtensionCommandContext, ): Promise { if (ctx.mode !== "tui") { notify(ctx, "Note fork requires Pi's interactive TUI", "warning"); return undefined; } const messages = userMessagesForForking( ctx.sessionManager.getEntries() as SessionEntryLike[], ); if (messages.length === 0) { notify(ctx, "No messages to fork from", "info"); return undefined; } const initialSelectedId = messages.at(-1)?.entryId; return ctx.ui.custom( (tui, _theme, _keybindings, done) => { const selector = new UserMessageSelectorComponent( messages.map(({ entryId, text }) => ({ id: entryId, text })), done, () => done(undefined), initialSelectedId, ); const list = selector.getMessageList(); return { invalidate: () => selector.invalidate(), render: (width: number) => selector.render(width), handleInput(keyData: string) { list.handleInput(keyData); tui.requestRender(); }, }; }, ); } function consumeSourceNote(launch: NoteLaunch): void { const { sourceSessionFile, sourceLeafId } = launch; if (!sourceSessionFile || !existsSync(sourceSessionFile)) return; const manager = SessionManager.open(sourceSessionFile); if (manager.getSessionId() !== launch.sourceSessionId) { throw new Error("Source session identity changed before note consumption"); } if (sourceLeafId) manager.branch(sourceLeafId); else manager.resetLeaf(); const sourceState = replayNotes(manager.getBranch() as SessionEntryLike[]); const note = noteWithKey(sourceState, launch.note.key); if (!note) { throw new Error("Source note changed before it could be consumed"); } manager.appendCustomEntry(ENTRY_TYPE, { op: "remove", id: note.id, removedAt: Date.now(), } satisfies NotesEvent); } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } const SUBCOMMANDS = [ "add", "send", "clone", "fork", "new", "edit", "rm", "clear", ] as const; function argumentCompletions( state: NotesState, argumentPrefix: string, ): AutocompleteItem[] { const { command, rest } = splitCommand(argumentPrefix); if (!argumentPrefix.includes(" ")) { return SUBCOMMANDS.filter((item) => item.startsWith(command)).map( (item) => ({ value: item, label: item, }), ); } if ( command !== "send" && command !== "clone" && command !== "fork" && command !== "new" && command !== "edit" && command !== "rm" ) return []; return pendingNotes(state) .filter((note) => String(note.id).startsWith(rest)) .map((note) => ({ value: `${command} ${note.id}`, label: `${note.id} ${preview(note.text)}`, })); } export default function notesExtension(pi: ExtensionAPI): void { const instanceToken = crypto.randomUUID(); const reservedNoteKeys = new Set(); const state = createState(); let expanded = false; const { shortcuts, errors: settingErrors } = readShortcuts(pi); function refreshWidget(ctx: ExtensionContext): void { if (!ctx.hasUI) return; if (state.notes.size === 0) { ctx.ui.setWidget(WIDGET_KEY, undefined); return; } ctx.ui.setWidget(WIDGET_KEY, () => ({ invalidate() {}, render(width: number): string[] { return widgetLines(state, expanded, width) ?? []; }, })); } function toggleWidget(ctx: ExtensionContext): void { if (state.notes.size === 0) { notify(ctx, "No pending notes", "info"); refreshWidget(ctx); return; } expanded = !expanded; refreshWidget(ctx); } function rejectReservedNote(ctx: ExtensionContext, note: Note): boolean { if (!reservedNoteKeys.has(note.key)) return false; notify(ctx, `Note ${note.id} is being delivered`, "warning"); return true; } function stageLaunch(ctx: ExtensionContext, launch: NoteLaunch): void { let staged = noteWithKey(state, launch.note.key); if (!staged) { appendEvent(pi, state, { op: "add", id: state.nextId, key: launch.note.key, text: launch.note.text, createdAt: Date.now(), }); staged = noteWithKey(state, launch.note.key); } else if (staged.text !== launch.note.text) { appendEvent(pi, state, { op: "edit", id: staged.id, text: launch.note.text, updatedAt: Date.now(), }); staged = noteWithKey(state, launch.note.key); } if (!staged) throw new Error("Failed to stage note in destination session"); reservedNoteKeys.add(launch.note.key); refreshWidget(ctx); let active = true; launch.receiver = { instanceToken, consume() { if (!active) return false; const note = noteWithKey(state, launch.note.key); if (!note) { reservedNoteKeys.delete(launch.note.key); return false; } appendEvent(pi, state, { op: "remove", id: note.id, removedAt: Date.now(), }); reservedNoteKeys.delete(launch.note.key); refreshWidget(ctx); return true; }, fail(message) { if (!active) return; reservedNoteKeys.delete(launch.note.key); notify(ctx, `Note remains pending: ${message}`, "error"); refreshWidget(ctx); }, revoke() { active = false; reservedNoteKeys.delete(launch.note.key); }, }; } async function launchNote( kind: NoteLaunchKind, note: Note, ctx: ExtensionCommandContext, ): Promise { if (!ctx.isIdle() || ctx.hasPendingMessages()) { notify(ctx, `Cannot ${kind} while the agent is busy`, "warning"); return; } if (activeLaunch()) { notify(ctx, "Another note session transition is active", "warning"); return; } const sourceSessionFile = ctx.sessionManager.getSessionFile() ?? undefined; if (sourceSessionFile && !existsSync(sourceSessionFile)) { notify( ctx, "Current session is not saved yet; wait for the first assistant response", "warning", ); return; } let targetEntryId: string | undefined; if (kind === "fork") { targetEntryId = await selectForkEntry(ctx); if (!targetEntryId) return; if (!ctx.isIdle() || ctx.hasPendingMessages()) { notify(ctx, "Cannot fork while the agent is busy", "warning"); return; } const currentNote = state.notes.get(note.id); if (currentNote?.key !== note.key || currentNote.text !== note.text) { notify( ctx, `Note ${note.id} changed while selecting a fork`, "warning", ); return; } } const sourceLeafId = ctx.sessionManager.getLeafId() ?? undefined; if (kind === "clone" && !sourceLeafId) { notify(ctx, "Nothing to clone yet", "info"); return; } const launch: NoteLaunch = { token: crypto.randomUUID(), sourceInstanceToken: instanceToken, sourceSessionId: ctx.sessionManager.getSessionId(), kind, note: structuredClone(note), sourceLeafId, sourceSessionFile, shutdownSeen: false, destinationClaimed: false, }; if (!startLaunch(launch)) { notify(ctx, "Another note session transition is active", "warning"); return; } const finish = span?.("note.session_transition", { kind }); let outcome: | "delivered" | "cancelled" | "handoff_missing" | "delivery_failed" | "source_pending" | "failed" = "failed"; try { const withSession = async (replacement: ReplacementContext) => { launch.replacementContext = replacement; }; const result = kind === "new" ? await ctx.newSession({ withSession }) : await ctx.fork(targetEntryId ?? sourceLeafId ?? "", { position: kind === "clone" ? "at" : "before", withSession, }); if (result.cancelled) { outcome = "cancelled"; return; } const replacement = launch.replacementContext; const receiver = launch.receiver; if (!replacement || !receiver) { outcome = "handoff_missing"; replacement?.ui.notify( "Note session transition completed without a destination handoff", "error", ); return; } replacement.ui.setEditorText(""); try { await replacement.sendUserMessage(launch.note.text, { expandPromptTemplates: true, }); } catch (error) { outcome = "delivery_failed"; receiver.fail(errorMessage(error)); return; } try { if (!receiver.consume()) { outcome = "delivery_failed"; return; } } catch (error) { outcome = "delivery_failed"; receiver.fail(errorMessage(error)); return; } try { consumeSourceNote(launch); } catch (error) { outcome = "source_pending"; replacement.ui.notify( `Note delivered; source copy remains pending: ${errorMessage(error)}`, "warning", ); } if (outcome === "failed") outcome = "delivered"; } catch (error) { outcome = "failed"; throw error; } finally { finish?.(outcome === "failed" ? "error" : "finish", { kind, outcome, }); const shutdownSeen = launch.shutdownSeen; finishLaunch(launch); if (shutdownSeen) closeDebug(); } } pi.registerCommand("note", { description: "Park, edit, send, or launch sessions from prompt notes.", getArgumentCompletions: (argumentPrefix) => argumentCompletions(state, argumentPrefix), async handler(args, ctx) { const trimmed = args.trim(); const { command, rest } = splitCommand(trimmed); if (!trimmed) { toggleWidget(ctx); return; } if (command === "add") { const text = rest.trim(); if (!text) { dbg?.("note.change", { kind: "add", outcome: "invalid" }); notify(ctx, "Note text required", "warning"); return; } const wasEmpty = state.notes.size === 0; appendEvent(pi, state, { op: "add", id: state.nextId, key: crypto.randomUUID(), text, createdAt: Date.now(), }); if (wasEmpty) expanded = false; refreshWidget(ctx); dbg?.("note.change", { kind: "add", outcome: "applied" }); return; } if (command === "addFromEditor") { if (ctx.mode !== "tui") { notify( ctx, "Note add from editor requires Pi's interactive TUI", "warning", ); return; } const editorText = ctx.ui.getEditorText(); const trimmed = editorText.trim(); if (!trimmed) { notify(ctx, "Editor is empty", "warning"); return; } const wasEmpty = state.notes.size === 0; appendEvent(pi, state, { op: "add", id: state.nextId, key: crypto.randomUUID(), text: trimmed, createdAt: Date.now(), }); if (wasEmpty) expanded = false; refreshWidget(ctx); ctx.ui.setEditorText(""); notify(ctx, `Note added: ${preview(trimmed)}`); return; } if (command === "rm") { const note = requireNote(ctx, state, rest); if (!note || rejectReservedNote(ctx, note)) return; appendEvent(pi, state, { op: "remove", id: note.id, removedAt: Date.now(), }); refreshWidget(ctx); return; } if (command === "clear") { if (reservedNoteKeys.size > 0) { notify(ctx, "A note is being delivered", "warning"); return; } if (state.notes.size === 0) { notify(ctx, "No pending notes", "info"); refreshWidget(ctx); return; } const confirmed = await ctx.ui.confirm( "Clear notes?", `Remove ${state.notes.size} pending notes?`, ); if (!confirmed) return; appendEvent(pi, state, { op: "clear", clearedAt: Date.now() }); refreshWidget(ctx); return; } if (command === "edit") { const note = requireNote(ctx, state, rest); if (!note || rejectReservedNote(ctx, note)) return; const edited = await ctx.ui.editor(`Edit note ${note.id}`, note.text); if (edited === undefined) return; const text = edited.trim(); if (!text) { notify(ctx, "Note text required", "warning"); return; } appendEvent(pi, state, { op: "edit", id: note.id, text, updatedAt: Date.now(), }); refreshWidget(ctx); return; } if (command === "send") { const note = requireNote(ctx, state, rest); if (!note || rejectReservedNote(ctx, note)) return; pi.sendUserMessage(note.text, { expandPromptTemplates: true, deliverAs: ctx.isIdle() ? undefined : "followUp", }); appendEvent(pi, state, { op: "remove", id: note.id, removedAt: Date.now(), }); refreshWidget(ctx); return; } if (command === "sendPicker") { if (ctx.mode !== "tui") { notify( ctx, "Note send picker requires Pi's interactive TUI", "warning", ); return; } const notes = pendingNotes(state); if (notes.length === 0) { notify(ctx, "No pending notes to send", "info"); return; } const selected = await ctx.ui.custom( (tui, _theme, _keybindings, done) => { const selector = new UserMessageSelectorComponent( notes.map((n) => ({ id: String(n.id), text: n.text })), done, () => done(undefined), notes.length === 1 ? String(notes[0].id) : undefined, ); const list = selector.getMessageList(); return { invalidate: () => selector.invalidate(), render: (width: number) => selector.render(width), handleInput(keyData: string) { list.handleInput(keyData); tui.requestRender(); }, }; }, ); if (selected === undefined) return; const note = state.notes.get(Number(selected)); if (!note || rejectReservedNote(ctx, note)) return; pi.sendUserMessage(note.text, { expandPromptTemplates: true, deliverAs: ctx.isIdle() ? undefined : "followUp", }); appendEvent(pi, state, { op: "remove", id: note.id, removedAt: Date.now(), }); refreshWidget(ctx); return; } if (command === "clone" || command === "fork" || command === "new") { const note = requireNote(ctx, state, rest); if (!note || rejectReservedNote(ctx, note)) return; await launchNote(command, note, ctx); return; } notify( ctx, "Usage: /note [add|send|clone|fork|new|edit|rm|clear]", "warning", ); refreshWidget(ctx); }, }); pi.registerShortcut(shortcuts.toggle, { description: "Toggle notes widget", handler: async (ctx) => toggleWidget(ctx), }); pi.registerShortcut(shortcuts.addFromEditor, { description: "Add editor text as a note", handler: async (ctx) => { if (ctx.mode !== "tui") { notify( ctx, "Note add from editor requires Pi's interactive TUI", "warning", ); return; } const editorText = ctx.ui.getEditorText(); const trimmed = editorText.trim(); if (!trimmed) { notify(ctx, "Editor is empty", "warning"); return; } const wasEmpty = state.notes.size === 0; appendEvent(pi, state, { op: "add", id: state.nextId, key: crypto.randomUUID(), text: trimmed, createdAt: Date.now(), }); if (wasEmpty) expanded = false; refreshWidget(ctx); ctx.ui.setEditorText(""); notify(ctx, `Note added: ${preview(trimmed)}`); }, }); pi.registerShortcut(shortcuts.sendPicker, { description: "Send a pending note via picker", handler: async (ctx) => { if (ctx.mode !== "tui") { notify( ctx, "Note send picker requires Pi's interactive TUI", "warning", ); return; } const notes = pendingNotes(state); if (notes.length === 0) { notify(ctx, "No pending notes to send", "info"); return; } const selected = await ctx.ui.custom( (tui, _theme, _keybindings, done) => { const selector = new UserMessageSelectorComponent( notes.map((n) => ({ id: String(n.id), text: n.text })), done, () => done(undefined), notes.length === 1 ? String(notes[0].id) : undefined, ); const list = selector.getMessageList(); return { invalidate: () => selector.invalidate(), render: (width: number) => selector.render(width), handleInput(keyData: string) { list.handleInput(keyData); tui.requestRender(); }, }; }, ); if (selected === undefined) return; const note = state.notes.get(Number(selected)); if (!note || rejectReservedNote(ctx, note)) return; pi.sendUserMessage(note.text, { expandPromptTemplates: true, deliverAs: ctx.isIdle() ? undefined : "followUp", }); appendEvent(pi, state, { op: "remove", id: note.id, removedAt: Date.now(), }); refreshWidget(ctx); }, }); function restoreBranch(ctx: ExtensionContext): void { replaceState( state, replayNotes(ctx.sessionManager.getBranch() as SessionEntryLike[]), ); expanded = false; refreshWidget(ctx); } pi.on("session_shutdown", (event) => { dbg?.("session.shutdown"); const launch = activeLaunch(); if (launch?.receiver?.instanceToken === instanceToken) { launch.receiver.revoke(); } if ( launch?.sourceInstanceToken === instanceToken && !launch.shutdownSeen && event.reason === launchReason(launch.kind) ) { launch.shutdownSeen = true; launch.targetSessionFile = event.targetSessionFile; return; } if (!launch) closeDebug(); if (event.reason === "new") { stashNewSessionNotes(event.targetSessionFile, pendingNotes(state)); } }); pi.on("session_start", async (event, ctx) => { dbg?.("session.start", { mode: ctx.mode }); for (const error of settingErrors) ctx.ui.notify(error, "warning"); restoreBranch(ctx); const launch = activeLaunch(); if ( launch?.shutdownSeen && !launch.destinationClaimed && event.reason === launchReason(launch.kind) && event.previousSessionFile === launch.sourceSessionFile && ctx.sessionManager.getSessionFile() === launch.targetSessionFile ) { launch.destinationClaimed = true; stageLaunch(ctx, launch); return; } if (event.reason !== "new") return; const notes = takeNewSessionNotes(ctx.sessionManager.getSessionFile()); if ( notes.length === 0 || !ctx.hasUI || !(await ctx.ui.confirm( "Carry notes?", `Copy ${notes.length} pending notes into the new session?`, )) ) { return; } for (const note of notes) { appendEvent(pi, state, { op: "add", id: note.id, key: note.key, text: note.text, createdAt: Date.now(), }); } refreshWidget(ctx); }); pi.on("session_tree", (_event, ctx) => restoreBranch(ctx)); } export const __test = { ENTRY_TYPE, clearLaunch: () => Reflect.deleteProperty(globalThis, NOTE_LAUNCH), argumentCompletions, compactWidgetLine, expandedWidgetLines, parseEvent, pendingNotes, preview, replayNotes, takeNewSessionNotes, userMessagesForForking, widgetLines, };