main
  1/**
  2 * Message Queue Extension
  3 *
  4 * Auto-queues messages when the agent is busy. Shows a widget below the editor
  5 * with the queue count. `/queue` opens a review/reorder UI before sending.
  6 *
  7 * Behavior:
  8 * - Type while agent is busy → message is auto-queued
  9 * - Widget below editor shows "📋 N queued" when queue is non-empty
 10 * - When agent becomes idle with queued messages → notification to review
 11 * - `/queue` (or Ctrl+Alt+Q) opens review UI:
 12 *   - ↑/↓ to navigate
 13 *   - Shift+↑/↓ to reorder
 14 *   - d to delete item
 15 *   - Enter to send all sequentially (one per turn)
 16 *   - a to send all concatenated as one message
 17 *   - Escape to close without sending
 18 */
 19
 20import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
 21import { DynamicBorder } from "@mariozechner/pi-coding-agent";
 22import { matchesKey, Key, truncateToWidth } from "@mariozechner/pi-tui";
 23
 24interface QueuedMessage {
 25	text: string;
 26	timestamp: number;
 27}
 28
 29class QueueReviewComponent {
 30	private items: QueuedMessage[];
 31	private selected: number = 0;
 32	private theme: Theme;
 33	private onSendSequential: (items: QueuedMessage[]) => void;
 34	private onSendConcatenated: (items: QueuedMessage[]) => void;
 35	private onClose: () => void;
 36	private cachedWidth?: number;
 37	private cachedLines?: string[];
 38
 39	constructor(
 40		items: QueuedMessage[],
 41		theme: Theme,
 42		onSendSequential: (items: QueuedMessage[]) => void,
 43		onSendConcatenated: (items: QueuedMessage[]) => void,
 44		onClose: () => void,
 45	) {
 46		this.items = [...items];
 47		this.theme = theme;
 48		this.onSendSequential = onSendSequential;
 49		this.onSendConcatenated = onSendConcatenated;
 50		this.onClose = onClose;
 51	}
 52
 53	getItems(): QueuedMessage[] {
 54		return [...this.items];
 55	}
 56
 57	handleInput(data: string): void {
 58		if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
 59			this.onClose();
 60			return;
 61		}
 62
 63		if (this.items.length === 0) {
 64			return;
 65		}
 66
 67		if (matchesKey(data, Key.up)) {
 68			if (this.selected > 0) {
 69				this.selected--;
 70				this.invalidate();
 71			}
 72			return;
 73		}
 74
 75		if (matchesKey(data, Key.down)) {
 76			if (this.selected < this.items.length - 1) {
 77				this.selected++;
 78				this.invalidate();
 79			}
 80			return;
 81		}
 82
 83		// Alt+Up: move item up
 84		if (matchesKey(data, Key.alt("up"))) {
 85			if (this.selected > 0) {
 86				const tmp = this.items[this.selected]!;
 87				this.items[this.selected] = this.items[this.selected - 1]!;
 88				this.items[this.selected - 1] = tmp;
 89				this.selected--;
 90				this.invalidate();
 91			}
 92			return;
 93		}
 94
 95		// Alt+Down: move item down
 96		if (matchesKey(data, Key.alt("down"))) {
 97			if (this.selected < this.items.length - 1) {
 98				const tmp = this.items[this.selected]!;
 99				this.items[this.selected] = this.items[this.selected + 1]!;
100				this.items[this.selected + 1] = tmp;
101				this.selected++;
102				this.invalidate();
103			}
104			return;
105		}
106
107		// d: delete selected
108		if (data === "d") {
109			this.items.splice(this.selected, 1);
110			if (this.selected >= this.items.length && this.selected > 0) {
111				this.selected = this.items.length - 1;
112			}
113			this.invalidate();
114			return;
115		}
116
117		// Enter: send all sequentially
118		if (matchesKey(data, Key.enter)) {
119			this.onSendSequential(this.items);
120			return;
121		}
122
123		// a: send all concatenated
124		if (data === "a") {
125			this.onSendConcatenated(this.items);
126			return;
127		}
128	}
129
130	render(width: number): string[] {
131		if (this.cachedLines && this.cachedWidth === width) {
132			return this.cachedLines;
133		}
134
135		const th = this.theme;
136		const lines: string[] = [];
137
138		// Top border
139		const borderFn = (s: string) => th.fg("accent", s);
140		const topBorder = new DynamicBorder(borderFn);
141		lines.push(...topBorder.render(width));
142
143		// Title
144		lines.push(truncateToWidth(`  ${th.fg("accent", th.bold("Message Queue"))}`, width));
145		lines.push("");
146
147		if (this.items.length === 0) {
148			lines.push(truncateToWidth(`  ${th.fg("dim", "Queue is empty")}`, width));
149		} else {
150			lines.push(truncateToWidth(`  ${th.fg("muted", `${this.items.length} message(s) queued:`)}`, width));
151			lines.push("");
152
153			for (let i = 0; i < this.items.length; i++) {
154				const item = this.items[i]!;
155				const isSelected = i === this.selected;
156				const prefix = isSelected ? th.fg("accent", "▸ ") : "  ";
157				const num = th.fg("dim", `${i + 1}.`);
158
159				// Truncate message preview
160				const maxMsgWidth = width - 8;
161				let preview = item.text.replace(/\n/g, "↵ ");
162				if (preview.length > maxMsgWidth) {
163					preview = preview.slice(0, maxMsgWidth - 1) + "…";
164				}
165
166				const msgText = isSelected ? th.fg("accent", preview) : th.fg("text", preview);
167				lines.push(truncateToWidth(`${prefix}${num} ${msgText}`, width));
168			}
169		}
170
171		lines.push("");
172
173		// Help text
174		const helpParts = [
175			"↑↓ navigate",
176			"Alt+↑↓ reorder",
177			"d delete",
178			"Enter send sequential",
179			"a send concatenated",
180			"Esc close",
181		];
182		lines.push(truncateToWidth(`  ${th.fg("dim", helpParts.join(" • "))}`, width));
183
184		// Bottom border
185		const bottomBorder = new DynamicBorder(borderFn);
186		lines.push(...bottomBorder.render(width));
187
188		this.cachedWidth = width;
189		this.cachedLines = lines;
190		return lines;
191	}
192
193	invalidate(): void {
194		this.cachedWidth = undefined;
195		this.cachedLines = undefined;
196	}
197}
198
199export default function (pi: ExtensionAPI) {
200	let queue: QueuedMessage[] = [];
201	let sending = false;
202	let pendingFollowUps: QueuedMessage[] = [];
203
204	// --- Widget ---
205
206	const updateWidget = (ctx: ExtensionContext) => {
207		if (queue.length === 0) {
208			ctx.ui.setWidget("message-queue", undefined);
209		} else {
210			ctx.ui.setWidget(
211				"message-queue",
212				(_tui, theme) => {
213					const label = theme.fg("accent", `📋 ${queue.length} queued`);
214					const hint = theme.fg("dim", " — /queue to review");
215					return {
216						render: () => [` ${label}${hint}`],
217						invalidate: () => {},
218					};
219				},
220				{ placement: "belowEditor" },
221			);
222		}
223	};
224
225	// --- Shared queue review UI ---
226
227	const showQueueUI = async (ctx: ExtensionContext) => {
228		if (!ctx.hasUI) {
229			ctx.ui.notify("/queue requires interactive mode", "error");
230			return;
231		}
232
233		if (queue.length === 0) {
234			ctx.ui.notify("Queue is empty", "info");
235			return;
236		}
237
238		const result = await ctx.ui.custom<{
239			mode: "sequential" | "concatenated" | "cancelled";
240			items: QueuedMessage[];
241		}>((tui, theme, _kb, done) => {
242			const component = new QueueReviewComponent(
243				queue,
244				theme,
245				(items) => done({ mode: "sequential", items }),
246				(items) => done({ mode: "concatenated", items }),
247				() => done({ mode: "cancelled", items: component.getItems() }),
248			);
249
250			return {
251				render: (w: number) => component.render(w),
252				invalidate: () => component.invalidate(),
253				handleInput: (data: string) => {
254					component.handleInput(data);
255					tui.requestRender();
256				},
257			};
258		});
259
260		// Always sync queue with remaining items (deletions persist across close)
261		queue = result.items;
262		updateWidget(ctx);
263
264		if (result.mode === "cancelled") {
265			return;
266		}
267
268		const items = result.items;
269
270		// Clear the queue before sending
271		queue = [];
272		updateWidget(ctx);
273
274		if (items.length === 0) {
275			ctx.ui.notify("All messages were deleted from queue", "info");
276			return;
277		}
278
279		sending = true;
280		try {
281			if (ctx.isIdle()) {
282				// Agent is idle — send first to start it, rest via agent_start handler
283				if (result.mode === "concatenated") {
284					const combined = items.map((m) => m.text).join("\n\n---\n\n");
285					pi.sendUserMessage(combined);
286				} else {
287					pi.sendUserMessage(items[0]!.text);
288					if (items.length > 1) {
289						pendingFollowUps = items.slice(1);
290					}
291				}
292			} else {
293				// Agent is busy — all can go as followUp
294				if (result.mode === "concatenated") {
295					const combined = items.map((m) => m.text).join("\n\n---\n\n");
296					pi.sendUserMessage(combined, { deliverAs: "followUp" });
297				} else {
298					for (const item of items) {
299						pi.sendUserMessage(item.text, { deliverAs: "followUp" });
300					}
301				}
302			}
303		} finally {
304			sending = false;
305		}
306	};
307
308	// --- Events ---
309
310	// Auto-queue messages when agent is busy AND there's already a message waiting
311	pi.on("input", async (event, ctx) => {
312		// Don't intercept messages we're sending from the queue
313		if (sending) {
314			return { action: "continue" as const };
315		}
316
317		// streamingBehavior is set ("steer"|"followUp") only when the agent is
318		// actively streaming; undefined means idle. Prefer it over isIdle() as it
319		// reflects how *this* input would be delivered, avoiding races with the
320		// idle-state read. Fall back to isIdle() if the host predates it.
321		const isStreaming = event.streamingBehavior !== undefined || !ctx.isIdle();
322		if (!isStreaming) {
323			return { action: "continue" as const };
324		}
325
326		// Ignore empty messages (e.g. stray Enter presses)
327		if (!event.text.trim()) {
328			return { action: "handled" as const };
329		}
330
331		// Only auto-queue if there's already something in the queue
332		// First message goes through as normal follow-up
333		if (queue.length === 0) {
334			return { action: "continue" as const };
335		}
336
337		// Deduplicate: skip if identical to the last queued message
338		const last = queue[queue.length - 1];
339		if (last && last.text === event.text) {
340			ctx.ui.notify("Duplicate message ignored", "info");
341			return { action: "handled" as const };
342		}
343
344		// Agent is busy AND queue has messages — queue this one too
345		queue.push({
346			text: event.text,
347			timestamp: Date.now(),
348		});
349
350		ctx.ui.notify(`Message queued (${queue.length} in queue)`, "info");
351		updateWidget(ctx);
352
353		return { action: "handled" as const };
354	});
355
356	// Dispatch pending follow-ups once the agent is actually running
357	pi.on("agent_start", async (_event, _ctx) => {
358		if (pendingFollowUps.length > 0) {
359			const followUps = pendingFollowUps;
360			pendingFollowUps = [];
361			sending = true;
362			try {
363				for (const item of followUps) {
364					pi.sendUserMessage(item.text, { deliverAs: "followUp" });
365				}
366			} finally {
367				sending = false;
368			}
369		}
370	});
371
372	// Notify when agent becomes idle and queue is non-empty
373	pi.on("agent_end", async (_event, ctx) => {
374		if (queue.length > 0) {
375			ctx.ui.notify(
376				`Agent idle — ${queue.length} message(s) queued. /queue to review & send`,
377				"info",
378			);
379		}
380	});
381
382	// Clear queue on session changes
383	pi.on("session_start", async (_event, ctx) => {
384		queue = [];
385		updateWidget(ctx);
386	});
387
388	pi.on("session_switch", async (_event, ctx) => {
389		queue = [];
390		updateWidget(ctx);
391	});
392
393	// --- Commands ---
394
395	pi.registerCommand("queue", {
396		description: "Review, reorder, and send queued messages. Usage: /queue [message to add]",
397		handler: async (args, ctx) => {
398			// If args provided, add to queue directly
399			if (args.trim()) {
400				// Deduplicate: skip if identical to the last queued message
401				const last = queue[queue.length - 1];
402				if (last && last.text === args) {
403					ctx.ui.notify("Duplicate message ignored", "info");
404					return;
405				}
406
407				queue.push({
408					text: args,
409					timestamp: Date.now(),
410				});
411
412				ctx.ui.notify(`Message queued (${queue.length} in queue)`, "info");
413				updateWidget(ctx);
414			} else {
415				// No args, show UI
416				await showQueueUI(ctx);
417			}
418		},
419	});
420
421	pi.registerCommand("queue-clear", {
422		description: "Clear all queued messages",
423		handler: async (_args, ctx) => {
424			const count = queue.length;
425			queue = [];
426			updateWidget(ctx);
427			ctx.ui.notify(count > 0 ? `Cleared ${count} message(s)` : "Queue was already empty", "info");
428		},
429	});
430
431	// --- Shortcut ---
432
433	pi.registerShortcut("ctrl+alt+q", {
434		description: "Open message queue",
435		handler: async (ctx) => {
436			await showQueueUI(ctx);
437		},
438	});
439}