Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/notes/index.ts

Raw
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<ExtensionAPI["registerShortcut"]>[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<Shortcut> => ({
	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<Shortcut>) => {
		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<number, Note>;
	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<void>;
};

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<string, unknown>;
	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<string | undefined, Note[]> {
	const existing = Reflect.get(globalThis, NEW_SESSION_NOTES);
	if (existing instanceof Map) return existing;
	const handoffs = new Map<string | undefined, Note[]>();
	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<string | undefined> {
	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<string | undefined>(
		(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<string>();
	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<void> {
		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<string | undefined>(
					(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<string | undefined>(
				(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,
};