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}