Commit 90e4f398ba53
Changed files (1)
dots
pi
agent
extensions
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);
+ },
+ });
+}