repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/notes/index.ts
Rawimport { 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,
};