flake-update-20260505
  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		if (ctx.isIdle()) {
318			return { action: "continue" as const };
319		}
320
321		// Ignore empty messages (e.g. stray Enter presses)
322		if (!event.text.trim()) {
323			return { action: "handled" as const };
324		}
325
326		// Only auto-queue if there's already something in the queue
327		// First message goes through as normal follow-up
328		if (queue.length === 0) {
329			return { action: "continue" as const };
330		}
331
332		// Deduplicate: skip if identical to the last queued message
333		const last = queue[queue.length - 1];
334		if (last && last.text === event.text) {
335			ctx.ui.notify("Duplicate message ignored", "info");
336			return { action: "handled" as const };
337		}
338
339		// Agent is busy AND queue has messages — queue this one too
340		queue.push({
341			text: event.text,
342			timestamp: Date.now(),
343		});
344
345		ctx.ui.notify(`Message queued (${queue.length} in queue)`, "info");
346		updateWidget(ctx);
347
348		return { action: "handled" as const };
349	});
350
351	// Dispatch pending follow-ups once the agent is actually running
352	pi.on("agent_start", async (_event, _ctx) => {
353		if (pendingFollowUps.length > 0) {
354			const followUps = pendingFollowUps;
355			pendingFollowUps = [];
356			sending = true;
357			try {
358				for (const item of followUps) {
359					pi.sendUserMessage(item.text, { deliverAs: "followUp" });
360				}
361			} finally {
362				sending = false;
363			}
364		}
365	});
366
367	// Notify when agent becomes idle and queue is non-empty
368	pi.on("agent_end", async (_event, ctx) => {
369		if (queue.length > 0) {
370			ctx.ui.notify(
371				`Agent idle — ${queue.length} message(s) queued. /queue to review & send`,
372				"info",
373			);
374		}
375	});
376
377	// Clear queue on session changes
378	pi.on("session_start", async (_event, ctx) => {
379		queue = [];
380		updateWidget(ctx);
381	});
382
383	pi.on("session_switch", async (_event, ctx) => {
384		queue = [];
385		updateWidget(ctx);
386	});
387
388	// --- Commands ---
389
390	pi.registerCommand("queue", {
391		description: "Review, reorder, and send queued messages. Usage: /queue [message to add]",
392		handler: async (args, ctx) => {
393			// If args provided, add to queue directly
394			if (args.trim()) {
395				// Deduplicate: skip if identical to the last queued message
396				const last = queue[queue.length - 1];
397				if (last && last.text === args) {
398					ctx.ui.notify("Duplicate message ignored", "info");
399					return;
400				}
401
402				queue.push({
403					text: args,
404					timestamp: Date.now(),
405				});
406
407				ctx.ui.notify(`Message queued (${queue.length} in queue)`, "info");
408				updateWidget(ctx);
409			} else {
410				// No args, show UI
411				await showQueueUI(ctx);
412			}
413		},
414	});
415
416	pi.registerCommand("queue-clear", {
417		description: "Clear all queued messages",
418		handler: async (_args, ctx) => {
419			const count = queue.length;
420			queue = [];
421			updateWidget(ctx);
422			ctx.ui.notify(count > 0 ? `Cleared ${count} message(s)` : "Queue was already empty", "info");
423		},
424	});
425
426	// --- Shortcut ---
427
428	pi.registerShortcut("ctrl+alt+q", {
429		description: "Open message queue",
430		handler: async (ctx) => {
431			await showQueueUI(ctx);
432		},
433	});
434}