Commit 90e4f398ba53

Vincent Demeester <vincent@sbr.pm>
2026-02-16 23:14:46
feat: add pi message queue extension
Added extension that auto-queues messages typed while the agent is busy. Provides a review UI (/queue or Ctrl+Shift+Q) to reorder, delete, and send messages sequentially or concatenated. Shows a widget below the editor with queue count.
1 parent 2025eb4
Changed files (1)
dots
pi
agent
dots/pi/agent/extensions/message-queue.ts
@@ -0,0 +1,409 @@
+/**
+ * Message Queue Extension
+ *
+ * Auto-queues messages when the agent is busy. Shows a widget below the editor
+ * with the queue count. `/queue` opens a review/reorder UI before sending.
+ *
+ * Behavior:
+ * - Type while agent is busy โ†’ message is auto-queued
+ * - Widget below editor shows "๐Ÿ“‹ N queued" when queue is non-empty
+ * - When agent becomes idle with queued messages โ†’ notification to review
+ * - `/queue` (or Ctrl+Shift+Q) opens review UI:
+ *   - โ†‘/โ†“ to navigate
+ *   - Shift+โ†‘/โ†“ to reorder
+ *   - d to delete item
+ *   - Enter to send all sequentially (one per turn)
+ *   - a to send all concatenated as one message
+ *   - Escape to close without sending
+ */
+
+import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
+import { DynamicBorder } from "@mariozechner/pi-coding-agent";
+import { matchesKey, Key, truncateToWidth } from "@mariozechner/pi-tui";
+
+interface QueuedMessage {
+	text: string;
+	timestamp: number;
+}
+
+class QueueReviewComponent {
+	private items: QueuedMessage[];
+	private selected: number = 0;
+	private theme: Theme;
+	private onSendSequential: (items: QueuedMessage[]) => void;
+	private onSendConcatenated: (items: QueuedMessage[]) => void;
+	private onClose: () => void;
+	private cachedWidth?: number;
+	private cachedLines?: string[];
+
+	constructor(
+		items: QueuedMessage[],
+		theme: Theme,
+		onSendSequential: (items: QueuedMessage[]) => void,
+		onSendConcatenated: (items: QueuedMessage[]) => void,
+		onClose: () => void,
+	) {
+		this.items = [...items];
+		this.theme = theme;
+		this.onSendSequential = onSendSequential;
+		this.onSendConcatenated = onSendConcatenated;
+		this.onClose = onClose;
+	}
+
+	getItems(): QueuedMessage[] {
+		return [...this.items];
+	}
+
+	handleInput(data: string): void {
+		if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
+			this.onClose();
+			return;
+		}
+
+		if (this.items.length === 0) {
+			return;
+		}
+
+		if (matchesKey(data, Key.up)) {
+			if (this.selected > 0) {
+				this.selected--;
+				this.invalidate();
+			}
+			return;
+		}
+
+		if (matchesKey(data, Key.down)) {
+			if (this.selected < this.items.length - 1) {
+				this.selected++;
+				this.invalidate();
+			}
+			return;
+		}
+
+		// Alt+Up: move item up
+		if (matchesKey(data, Key.alt("up"))) {
+			if (this.selected > 0) {
+				const tmp = this.items[this.selected]!;
+				this.items[this.selected] = this.items[this.selected - 1]!;
+				this.items[this.selected - 1] = tmp;
+				this.selected--;
+				this.invalidate();
+			}
+			return;
+		}
+
+		// Alt+Down: move item down
+		if (matchesKey(data, Key.alt("down"))) {
+			if (this.selected < this.items.length - 1) {
+				const tmp = this.items[this.selected]!;
+				this.items[this.selected] = this.items[this.selected + 1]!;
+				this.items[this.selected + 1] = tmp;
+				this.selected++;
+				this.invalidate();
+			}
+			return;
+		}
+
+		// d: delete selected
+		if (data === "d") {
+			this.items.splice(this.selected, 1);
+			if (this.selected >= this.items.length && this.selected > 0) {
+				this.selected = this.items.length - 1;
+			}
+			this.invalidate();
+			return;
+		}
+
+		// Enter: send all sequentially
+		if (matchesKey(data, Key.enter)) {
+			this.onSendSequential(this.items);
+			return;
+		}
+
+		// a: send all concatenated
+		if (data === "a") {
+			this.onSendConcatenated(this.items);
+			return;
+		}
+	}
+
+	render(width: number): string[] {
+		if (this.cachedLines && this.cachedWidth === width) {
+			return this.cachedLines;
+		}
+
+		const th = this.theme;
+		const lines: string[] = [];
+
+		// Top border
+		const borderFn = (s: string) => th.fg("accent", s);
+		const topBorder = new DynamicBorder(borderFn);
+		lines.push(...topBorder.render(width));
+
+		// Title
+		lines.push(truncateToWidth(`  ${th.fg("accent", th.bold("Message Queue"))}`, width));
+		lines.push("");
+
+		if (this.items.length === 0) {
+			lines.push(truncateToWidth(`  ${th.fg("dim", "Queue is empty")}`, width));
+		} else {
+			lines.push(truncateToWidth(`  ${th.fg("muted", `${this.items.length} message(s) queued:`)}`, width));
+			lines.push("");
+
+			for (let i = 0; i < this.items.length; i++) {
+				const item = this.items[i]!;
+				const isSelected = i === this.selected;
+				const prefix = isSelected ? th.fg("accent", "โ–ธ ") : "  ";
+				const num = th.fg("dim", `${i + 1}.`);
+
+				// Truncate message preview
+				const maxMsgWidth = width - 8;
+				let preview = item.text.replace(/\n/g, "โ†ต ");
+				if (preview.length > maxMsgWidth) {
+					preview = preview.slice(0, maxMsgWidth - 1) + "โ€ฆ";
+				}
+
+				const msgText = isSelected ? th.fg("accent", preview) : th.fg("text", preview);
+				lines.push(truncateToWidth(`${prefix}${num} ${msgText}`, width));
+			}
+		}
+
+		lines.push("");
+
+		// Help text
+		const helpParts = [
+			"โ†‘โ†“ navigate",
+			"Alt+โ†‘โ†“ reorder",
+			"d delete",
+			"Enter send sequential",
+			"a send concatenated",
+			"Esc close",
+		];
+		lines.push(truncateToWidth(`  ${th.fg("dim", helpParts.join(" โ€ข "))}`, width));
+
+		// Bottom border
+		const bottomBorder = new DynamicBorder(borderFn);
+		lines.push(...bottomBorder.render(width));
+
+		this.cachedWidth = width;
+		this.cachedLines = lines;
+		return lines;
+	}
+
+	invalidate(): void {
+		this.cachedWidth = undefined;
+		this.cachedLines = undefined;
+	}
+}
+
+export default function (pi: ExtensionAPI) {
+	let queue: QueuedMessage[] = [];
+	let sending = false;
+	let pendingFollowUps: QueuedMessage[] = [];
+
+	// --- Widget ---
+
+	const updateWidget = (ctx: ExtensionContext) => {
+		if (queue.length === 0) {
+			ctx.ui.setWidget("message-queue", undefined);
+		} else {
+			ctx.ui.setWidget(
+				"message-queue",
+				(_tui, theme) => {
+					const label = theme.fg("accent", `๐Ÿ“‹ ${queue.length} queued`);
+					const hint = theme.fg("dim", " โ€” /queue to review");
+					return {
+						render: () => [` ${label}${hint}`],
+						invalidate: () => {},
+					};
+				},
+				{ placement: "belowEditor" },
+			);
+		}
+	};
+
+	// --- Shared queue review UI ---
+
+	const showQueueUI = async (ctx: ExtensionContext) => {
+		if (!ctx.hasUI) {
+			ctx.ui.notify("/queue requires interactive mode", "error");
+			return;
+		}
+
+		if (queue.length === 0) {
+			ctx.ui.notify("Queue is empty", "info");
+			return;
+		}
+
+		const result = await ctx.ui.custom<{
+			mode: "sequential" | "concatenated" | "cancelled";
+			items: QueuedMessage[];
+		}>((tui, theme, _kb, done) => {
+			const component = new QueueReviewComponent(
+				queue,
+				theme,
+				(items) => done({ mode: "sequential", items }),
+				(items) => done({ mode: "concatenated", items }),
+				() => done({ mode: "cancelled", items: component.getItems() }),
+			);
+
+			return {
+				render: (w: number) => component.render(w),
+				invalidate: () => component.invalidate(),
+				handleInput: (data: string) => {
+					component.handleInput(data);
+					tui.requestRender();
+				},
+			};
+		});
+
+		// Always sync queue with remaining items (deletions persist across close)
+		queue = result.items;
+		updateWidget(ctx);
+
+		if (result.mode === "cancelled") {
+			return;
+		}
+
+		const items = result.items;
+
+		// Clear the queue before sending
+		queue = [];
+		updateWidget(ctx);
+
+		if (items.length === 0) {
+			ctx.ui.notify("All messages were deleted from queue", "info");
+			return;
+		}
+
+		sending = true;
+		try {
+			if (ctx.isIdle()) {
+				// Agent is idle โ€” send first to start it, rest via agent_start handler
+				if (result.mode === "concatenated") {
+					const combined = items.map((m) => m.text).join("\n\n---\n\n");
+					pi.sendUserMessage(combined);
+				} else {
+					pi.sendUserMessage(items[0]!.text);
+					if (items.length > 1) {
+						pendingFollowUps = items.slice(1);
+					}
+				}
+			} else {
+				// Agent is busy โ€” all can go as followUp
+				if (result.mode === "concatenated") {
+					const combined = items.map((m) => m.text).join("\n\n---\n\n");
+					pi.sendUserMessage(combined, { deliverAs: "followUp" });
+				} else {
+					for (const item of items) {
+						pi.sendUserMessage(item.text, { deliverAs: "followUp" });
+					}
+				}
+			}
+		} finally {
+			sending = false;
+		}
+	};
+
+	// --- Events ---
+
+	// Auto-queue messages when agent is busy
+	pi.on("input", async (event, ctx) => {
+		// Don't intercept messages we're sending from the queue
+		if (sending) {
+			return { action: "continue" as const };
+		}
+
+		if (ctx.isIdle()) {
+			return { action: "continue" as const };
+		}
+
+		// Ignore empty messages (e.g. stray Enter presses)
+		if (!event.text.trim()) {
+			return { action: "handled" as const };
+		}
+
+		// Deduplicate: skip if identical to the last queued message
+		const last = queue[queue.length - 1];
+		if (last && last.text === event.text) {
+			ctx.ui.notify("Duplicate message ignored", "info");
+			return { action: "handled" as const };
+		}
+
+		// Agent is busy โ€” queue the message
+		queue.push({
+			text: event.text,
+			timestamp: Date.now(),
+		});
+
+		ctx.ui.notify(`Message queued (${queue.length} in queue)`, "info");
+		updateWidget(ctx);
+
+		return { action: "handled" as const };
+	});
+
+	// Dispatch pending follow-ups once the agent is actually running
+	pi.on("agent_start", async (_event, _ctx) => {
+		if (pendingFollowUps.length > 0) {
+			const followUps = pendingFollowUps;
+			pendingFollowUps = [];
+			sending = true;
+			try {
+				for (const item of followUps) {
+					pi.sendUserMessage(item.text, { deliverAs: "followUp" });
+				}
+			} finally {
+				sending = false;
+			}
+		}
+	});
+
+	// Notify when agent becomes idle and queue is non-empty
+	pi.on("agent_end", async (_event, ctx) => {
+		if (queue.length > 0) {
+			ctx.ui.notify(
+				`Agent idle โ€” ${queue.length} message(s) queued. /queue to review & send`,
+				"info",
+			);
+		}
+	});
+
+	// Clear queue on session changes
+	pi.on("session_start", async (_event, ctx) => {
+		queue = [];
+		updateWidget(ctx);
+	});
+
+	pi.on("session_switch", async (_event, ctx) => {
+		queue = [];
+		updateWidget(ctx);
+	});
+
+	// --- Commands ---
+
+	pi.registerCommand("queue", {
+		description: "Review, reorder, and send queued messages",
+		handler: async (_args, ctx) => {
+			await showQueueUI(ctx);
+		},
+	});
+
+	pi.registerCommand("queue-clear", {
+		description: "Clear all queued messages",
+		handler: async (_args, ctx) => {
+			const count = queue.length;
+			queue = [];
+			updateWidget(ctx);
+			ctx.ui.notify(count > 0 ? `Cleared ${count} message(s)` : "Queue was already empty", "info");
+		},
+	});
+
+	// --- Shortcut ---
+
+	pi.registerShortcut("ctrl+shift+q", {
+		description: "Open message queue",
+		handler: async (ctx) => {
+			await showQueueUI(ctx);
+		},
+	});
+}