@kaiserlich-dev/pi-queue-picker 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaiserlich Dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @kaiserlich-dev/pi-queue-picker
2
+
3
+ A [pi](https://github.com/mariozechner/pi) extension that lets you choose between **Steer** and **Follow-up** when queuing messages while the agent is busy.
4
+
5
+ ## What it does
6
+
7
+ By default, pressing Enter while the agent is working queues your message as a "steer" (interrupts and redirects). This extension adds an interactive picker so you can choose:
8
+
9
+ - **Steer** — interrupt and redirect the agent
10
+ - **Follow-up** — queue for after the current task finishes
11
+
12
+ Queued follow-ups are **editable** in an internal buffer, so you can change mode and order before delivery.
13
+
14
+ ## Usage
15
+
16
+ When the agent is idle, Enter submits normally. When the agent is busy:
17
+
18
+ 1. Type your message and press **Enter**
19
+ 2. A centered delivery popup appears with **Steer** and **Follow-up** options
20
+ 3. **Tab** or **↑↓** to switch between modes
21
+ 4. **Enter** to send with the selected mode
22
+ 5. **Escape** to cancel (restores your text)
23
+
24
+ The picker remembers your last chosen mode as the default.
25
+
26
+ ### Editing queued messages
27
+
28
+ Queued messages stay in an editable buffer until the agent finishes. To edit them:
29
+
30
+ - Press **Ctrl+J** or type `/edit-queue`
31
+ - A floating popup overlay appears showing all buffered queue items (steer + follow-up)
32
+ - **↑↓** to navigate between messages
33
+ - **Tab** to toggle mode (follow-up ↔ steer)
34
+ - **j / k** to move a message up/down (reorder)
35
+ - **e** to edit the selected message in an inline text box
36
+ - **d** or **Delete** to remove the selected message
37
+ - **Enter** to confirm queue changes (or save while editing)
38
+ - **Escape** to cancel (or exit edit mode)
39
+
40
+ Messages keep their selected mode and queue order when you confirm. Deleted messages are discarded.
41
+
42
+ A widget above the editor shows buffered queue items:
43
+ ```
44
+ ⚡ Steer: also check the tests
45
+ 📋 Follow-up: and update the docs
46
+ ↳ Ctrl+J queue editor · e edit · d delete · j/k move
47
+ ```
48
+
49
+ ### How delivery works
50
+
51
+ - **Steer** messages are sent immediately (interrupt current task)
52
+ - **Follow-up** messages are buffered and flushed one at a time when the agent finishes (`agent_end` event)
53
+ - You can reorder items and toggle mode before they are sent
54
+ - If you change a queued item to **Steer** in the queue editor while the agent is busy, it is injected immediately after you save
55
+ - If the agent finishes while the picker is shown, the first queued item is flushed immediately after selection
56
+
57
+ ### Regression test
58
+
59
+ Run the steer-injection regression test:
60
+
61
+ ```bash
62
+ npm run test:regression
63
+ ```
64
+
65
+ This launches pi in tmux with the local extension, reproduces the queue-edit toggle flow, and asserts that changing a queued item to **Steer** injects it immediately while the agent is still busy.
66
+
67
+ ## Install
68
+
69
+ ### npm (recommended)
70
+
71
+ ```bash
72
+ pi install npm:@kaiserlich-dev/pi-queue-picker
73
+ ```
74
+
75
+ ### git (alternative)
76
+
77
+ ```bash
78
+ pi install git:github.com/kaiserlich-dev/pi-queue-picker
79
+ ```
80
+
81
+ > By default this writes to `~/.pi/agent/settings.json`. Use `-l` to install into `.pi/settings.json` for a project.
82
+
83
+ Then restart pi or run `/reload`.
84
+
85
+ ## Compatibility
86
+
87
+ Works alongside other extensions that customize the editor (e.g. `pi-powerline-footer`). Uses the `input` event API instead of replacing the editor component.
88
+
89
+ ### SSH / Mobile Terminals
90
+
91
+ The picker is automatically disabled over SSH (detected via `SSH_TTY`/`SSH_CONNECTION`), since mobile terminal apps like Terminus can't handle the custom TUI component. Messages fall through to the default steer behavior.
92
+
93
+ To manually disable the picker, set `PI_QUEUE_PICKER_DISABLE=1`.
@@ -0,0 +1,67 @@
1
+ import { truncateToWidth } from "@mariozechner/pi-tui";
2
+ import type { BufferedMessage } from "./types";
3
+
4
+ /**
5
+ * Add a message to the buffer.
6
+ */
7
+ export function addMessage(buffer: BufferedMessage[], msg: BufferedMessage): void {
8
+ buffer.push(msg);
9
+ }
10
+
11
+ /**
12
+ * Shift the next message from the front of the buffer.
13
+ * Used when flushing queued messages after agent finishes.
14
+ */
15
+ export function shiftNext(buffer: BufferedMessage[]): BufferedMessage | undefined {
16
+ return buffer.shift();
17
+ }
18
+
19
+ /**
20
+ * Find and remove the first "steer" message from the buffer.
21
+ * Steer messages should interrupt immediately even while agent is busy.
22
+ */
23
+ export function shiftNextSteer(buffer: BufferedMessage[]): BufferedMessage | undefined {
24
+ const index = buffer.findIndex((m) => m.mode === "steer");
25
+ if (index === -1) return undefined;
26
+ return buffer.splice(index, 1)[0];
27
+ }
28
+
29
+ /**
30
+ * Update the queue widget display.
31
+ * Shows a summary of buffered messages below the editor, or clears it if empty.
32
+ */
33
+ export function updateWidget(ui: any, buffer: BufferedMessage[]): void {
34
+ if (!ui) return;
35
+ if (buffer.length === 0) {
36
+ ui.setWidget("queue-picker", undefined);
37
+ return;
38
+ }
39
+ ui.setWidget(
40
+ "queue-picker",
41
+ (_tui: any, theme: any) => {
42
+ return {
43
+ render: (width: number) => {
44
+ const safeWidth = Math.max(1, width);
45
+ const lines = buffer.map((msg) => {
46
+ const prefix =
47
+ msg.mode === "steer"
48
+ ? "⚡ Steer"
49
+ : "📋 Follow-up";
50
+ return truncateToWidth(
51
+ theme.fg("dim", ` ${prefix}: ${msg.text}`),
52
+ safeWidth
53
+ );
54
+ });
55
+ lines.push(
56
+ truncateToWidth(
57
+ theme.fg("dim", " ↳ Ctrl+J queue editor · e edit · d delete · j/k move"),
58
+ safeWidth
59
+ )
60
+ );
61
+ return lines;
62
+ },
63
+ invalidate() {},
64
+ };
65
+ }
66
+ );
67
+ }
@@ -0,0 +1,52 @@
1
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
2
+ import type { Theme } from "../types";
3
+
4
+ export function pad(s: string, len: number): string {
5
+ const vis = visibleWidth(s);
6
+ return s + " ".repeat(Math.max(0, len - vis));
7
+ }
8
+
9
+ export function makeBox(innerW: number, theme: Theme) {
10
+ function row(content = ""): string {
11
+ const clipped = truncateToWidth(content, innerW - 1, "");
12
+ const vis = visibleWidth(clipped);
13
+ const padLen = Math.max(0, innerW - vis - 1);
14
+ return (
15
+ theme.fg("borderAccent", "│") +
16
+ " " +
17
+ clipped +
18
+ " ".repeat(padLen) +
19
+ theme.fg("borderAccent", "│")
20
+ );
21
+ }
22
+
23
+ function emptyRow(): string {
24
+ return (
25
+ theme.fg("borderAccent", "│") +
26
+ " ".repeat(innerW) +
27
+ theme.fg("borderAccent", "│")
28
+ );
29
+ }
30
+
31
+ function divider(): string {
32
+ return theme.fg("borderAccent", `├${"─".repeat(innerW)}┤`);
33
+ }
34
+
35
+ function topBorder(title: string): string {
36
+ const titleText = ` ${title} `;
37
+ const borderLen = Math.max(0, innerW - titleText.length);
38
+ const left = Math.floor(borderLen / 2);
39
+ const right = borderLen - left;
40
+ return (
41
+ theme.fg("borderAccent", `╭${"─".repeat(left)}`) +
42
+ theme.fg("accent", titleText) +
43
+ theme.fg("borderAccent", `${"─".repeat(right)}╮`)
44
+ );
45
+ }
46
+
47
+ function bottomBorder(): string {
48
+ return theme.fg("borderAccent", `╰${"─".repeat(innerW)}╯`);
49
+ }
50
+
51
+ return { row, emptyRow, divider, topBorder, bottomBorder };
52
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Queue Picker — choose between steering and follow-up when queuing messages.
3
+ *
4
+ * When the agent is busy and you submit a message, a picker appears:
5
+ * - Tab or ↑↓ to toggle between Steer and Follow-up
6
+ * - Enter to send with the selected mode
7
+ * - Escape to cancel and restore your text
8
+ *
9
+ * Queued messages are held in an internal buffer so you can edit them
10
+ * before they're delivered. Press Ctrl+J (or /edit-queue) to open a popup:
11
+ * - Toggle mode (follow-up ↔ steer)
12
+ * - Reorder messages (j / k)
13
+ * - Edit a queued message inline (e)
14
+ * - Delete messages from the queue (d/delete)
15
+ *
16
+ * Queue items are flushed one at a time when the agent finishes.
17
+ *
18
+ * The picker remembers your last chosen mode as the default.
19
+ *
20
+ * When the agent is idle, messages submit normally.
21
+ *
22
+ * Uses the `input` event instead of a custom editor, so it's compatible
23
+ * with other extensions that customize the editor (e.g. pi-powerline-footer).
24
+ */
25
+
26
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
27
+ import type { BufferedMessage, PickerMode } from "./types";
28
+ import { nextId } from "./types";
29
+ import { addMessage, shiftNext, shiftNextSteer, updateWidget } from "./buffer";
30
+ import {
31
+ handleModePickerInput,
32
+ renderModePicker,
33
+ type ModePickerState,
34
+ } from "./screens/mode-picker";
35
+ import {
36
+ createQueueEditorState,
37
+ handleQueueEditorInput,
38
+ renderQueueEditor,
39
+ } from "./screens/queue-editor";
40
+
41
+ /** Detect limited terminals (SSH from mobile apps like Terminus) where custom TUI components crash. */
42
+ function isLimitedTerminal(): boolean {
43
+ if (process.env.PI_QUEUE_PICKER_DISABLE === "1") return true;
44
+ if (process.env.SSH_TTY || process.env.SSH_CONNECTION) return true;
45
+ return false;
46
+ }
47
+
48
+ const PICKER_WIDTH = 72;
49
+ const BOX_WIDTH = 76;
50
+
51
+ export default function (pi: ExtensionAPI) {
52
+ let uiRef: any = null;
53
+ let lastMode: PickerMode = "steer";
54
+ let buffer: BufferedMessage[] = [];
55
+ let editingQueue = false;
56
+
57
+ // --- Helpers ---
58
+
59
+ function sendToPi(text: string, isIdle: boolean, mode: PickerMode) {
60
+ if (isIdle) {
61
+ pi.sendUserMessage(text);
62
+ } else {
63
+ pi.sendUserMessage(text, { deliverAs: mode });
64
+ }
65
+ }
66
+
67
+ function flushOneQueuedMessage(isIdle: boolean) {
68
+ const next = shiftNext(buffer);
69
+ if (!next) return;
70
+ sendToPi(next.text, isIdle, next.mode);
71
+ updateWidget(uiRef, buffer);
72
+ }
73
+
74
+ function flushOneSteerWhileBusy() {
75
+ const steerMsg = shiftNextSteer(buffer);
76
+ if (!steerMsg) return;
77
+ sendToPi(steerMsg.text, false, "steer");
78
+ updateWidget(uiRef, buffer);
79
+ }
80
+
81
+ function clearBuffer() {
82
+ buffer = [];
83
+ updateWidget(uiRef, buffer);
84
+ }
85
+
86
+ // --- Edit queue overlay popup ---
87
+
88
+ async function editQueue(ctx: any) {
89
+ if (buffer.length === 0) {
90
+ ctx.ui.notify("No queued messages", "info");
91
+ return;
92
+ }
93
+
94
+ editingQueue = true;
95
+
96
+ const editorState = createQueueEditorState(buffer);
97
+
98
+ const result: BufferedMessage[] | null = await ctx.ui.custom(
99
+ (
100
+ tui: any,
101
+ theme: any,
102
+ _kb: any,
103
+ done: (v: BufferedMessage[] | null) => void
104
+ ) => {
105
+ return {
106
+ render(_width: number): string[] {
107
+ return renderQueueEditor(editorState, BOX_WIDTH, theme);
108
+ },
109
+ invalidate() {},
110
+ handleInput(data: string) {
111
+ const action = handleQueueEditorInput(editorState, data);
112
+ if (action) {
113
+ switch (action.type) {
114
+ case "save":
115
+ done(action.items);
116
+ return;
117
+ case "cancel":
118
+ done(null);
119
+ return;
120
+ }
121
+ }
122
+ tui.requestRender();
123
+ },
124
+ };
125
+ },
126
+ {
127
+ overlay: true,
128
+ overlayOptions: {
129
+ anchor: "center" as any,
130
+ width: BOX_WIDTH + 2,
131
+ },
132
+ }
133
+ );
134
+
135
+ editingQueue = false;
136
+
137
+ if (result === null) {
138
+ return;
139
+ }
140
+
141
+ buffer = result;
142
+ updateWidget(uiRef, buffer);
143
+
144
+ if (ctx.isIdle() && buffer.length > 0) {
145
+ flushOneQueuedMessage(true);
146
+ } else if (!ctx.isIdle()) {
147
+ flushOneSteerWhileBusy();
148
+ }
149
+ }
150
+
151
+ // --- Events ---
152
+
153
+ pi.on("session_start", (_event, ctx) => {
154
+ uiRef = ctx.ui;
155
+ clearBuffer();
156
+ });
157
+
158
+ pi.on("session_switch", (_event, ctx) => {
159
+ uiRef = ctx.ui;
160
+ clearBuffer();
161
+ });
162
+
163
+ pi.on("agent_end", async (_event, ctx) => {
164
+ if (editingQueue || buffer.length === 0) return;
165
+ flushOneQueuedMessage(ctx.isIdle());
166
+ });
167
+
168
+ pi.on("input", async (event, ctx) => {
169
+ if (event.source !== "interactive" || ctx.isIdle()) {
170
+ return { action: "continue" as const };
171
+ }
172
+
173
+ if (!ctx.hasUI || !event.text.trim()) {
174
+ return { action: "continue" as const };
175
+ }
176
+
177
+ if (isLimitedTerminal()) {
178
+ return { action: "continue" as const };
179
+ }
180
+
181
+ // Agent is busy — show the picker
182
+ const pickerState: ModePickerState = {
183
+ selected: lastMode,
184
+ messageText: event.text,
185
+ };
186
+
187
+ const mode = await ctx.ui.custom<PickerMode | null>(
188
+ (tui, theme, _kb, done) => {
189
+ return {
190
+ render(_width: number): string[] {
191
+ return renderModePicker(pickerState, PICKER_WIDTH, theme);
192
+ },
193
+ invalidate() {},
194
+ handleInput(data: string) {
195
+ const action = handleModePickerInput(pickerState, data);
196
+ if (action) {
197
+ switch (action.type) {
198
+ case "select":
199
+ done(action.mode);
200
+ return;
201
+ case "cancel":
202
+ done(null);
203
+ return;
204
+ }
205
+ }
206
+ tui.requestRender();
207
+ },
208
+ };
209
+ },
210
+ {
211
+ overlay: true,
212
+ overlayOptions: {
213
+ anchor: "center" as any,
214
+ width: PICKER_WIDTH + 2,
215
+ },
216
+ }
217
+ );
218
+
219
+ if (mode === null) {
220
+ ctx.ui.setEditorText(event.text);
221
+ return { action: "handled" as const };
222
+ }
223
+
224
+ lastMode = mode;
225
+
226
+ if (mode === "steer") {
227
+ pi.sendUserMessage(event.text, { deliverAs: "steer" });
228
+ ctx.ui.notify(`Steer: ${event.text}`, "info");
229
+ } else {
230
+ addMessage(buffer, {
231
+ text: event.text,
232
+ mode,
233
+ id: nextId(),
234
+ });
235
+ updateWidget(uiRef, buffer);
236
+ ctx.ui.notify(
237
+ `Queued follow-up: ${event.text}`,
238
+ "info"
239
+ );
240
+
241
+ if (ctx.isIdle()) {
242
+ flushOneQueuedMessage(true);
243
+ }
244
+ }
245
+
246
+ return { action: "handled" as const };
247
+ });
248
+
249
+ // --- Shortcut & Command ---
250
+
251
+ pi.registerShortcut("ctrl+j", {
252
+ description: "Edit queued messages",
253
+ handler: (ctx) => editQueue(ctx),
254
+ });
255
+
256
+ pi.registerCommand("edit-queue", {
257
+ description: "Edit queued messages",
258
+ handler: (_args, ctx) => editQueue(ctx),
259
+ });
260
+ }
@@ -0,0 +1,92 @@
1
+ import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
2
+ import type { Theme, PickerMode, ModePickerAction } from "../types";
3
+ import { makeBox } from "../lib/render-helpers";
4
+
5
+ export interface ModePickerState {
6
+ selected: PickerMode;
7
+ messageText: string;
8
+ }
9
+
10
+ /**
11
+ * Handle input for the mode picker overlay.
12
+ * Returns an action when the user makes a selection or cancels,
13
+ * otherwise mutates state and returns undefined.
14
+ */
15
+ export function handleModePickerInput(
16
+ state: ModePickerState,
17
+ data: string
18
+ ): ModePickerAction | undefined {
19
+ if (
20
+ matchesKey(data, "tab") ||
21
+ matchesKey(data, "up") ||
22
+ matchesKey(data, "down")
23
+ ) {
24
+ state.selected = state.selected === "steer" ? "followUp" : "steer";
25
+ return;
26
+ }
27
+ if (matchesKey(data, "left")) {
28
+ state.selected = "steer";
29
+ return;
30
+ }
31
+ if (matchesKey(data, "right")) {
32
+ state.selected = "followUp";
33
+ return;
34
+ }
35
+ if (matchesKey(data, "return")) {
36
+ return { type: "select", mode: state.selected };
37
+ }
38
+ if (matchesKey(data, "escape")) {
39
+ return { type: "cancel" };
40
+ }
41
+ return;
42
+ }
43
+
44
+ /**
45
+ * Render the mode picker overlay.
46
+ * @param state - Current picker state
47
+ * @param width - Box width (rendered lines will be this wide)
48
+ * @param theme - Theme for styling
49
+ */
50
+ export function renderModePicker(
51
+ state: ModePickerState,
52
+ width: number,
53
+ theme: Theme
54
+ ): string[] {
55
+ const innerW = width - 2;
56
+ const { row, emptyRow, divider, topBorder, bottomBorder } = makeBox(innerW, theme);
57
+
58
+ const lines: string[] = [];
59
+
60
+ lines.push(topBorder("Delivery"));
61
+ lines.push(emptyRow());
62
+ lines.push(row(` ${theme.bold(theme.fg("accent", "↳ Deliver queued message as"))}`));
63
+ lines.push(row(` ${theme.fg("muted", truncateToWidth(state.messageText, innerW - 8, "…"))}`));
64
+ lines.push(emptyRow());
65
+ lines.push(divider());
66
+ lines.push(emptyRow());
67
+
68
+ const steerSel = state.selected === "steer";
69
+ const followSel = state.selected === "followUp";
70
+
71
+ lines.push(
72
+ row(
73
+ ` ${steerSel ? theme.fg("accent", "▸") : theme.fg("dim", "·")} ${steerSel ? theme.bold(theme.fg("accent", "⚡ STEER")) : theme.bold(theme.fg("warning", "⚡ STEER"))} ${theme.fg("dim", "Interrupt and redirect now")}`
74
+ )
75
+ );
76
+ lines.push(
77
+ row(
78
+ ` ${followSel ? theme.fg("accent", "▸") : theme.fg("dim", "·")} ${followSel ? theme.bold(theme.fg("accent", "📋 FOLLOW-UP")) : theme.bold(theme.fg("success", "📋 FOLLOW-UP"))} ${theme.fg("dim", "Run after current task")}`
79
+ )
80
+ );
81
+
82
+ lines.push(emptyRow());
83
+ lines.push(divider());
84
+ lines.push(
85
+ row(
86
+ `${theme.fg("muted", "tab/↑↓")} ${theme.fg("dim", "switch")} ${theme.fg("muted", "enter")} ${theme.fg("dim", "send")} ${theme.fg("muted", "esc")} ${theme.fg("dim", "cancel")}`
87
+ )
88
+ );
89
+ lines.push(bottomBorder());
90
+
91
+ return lines;
92
+ }
@@ -0,0 +1,226 @@
1
+ import { Input, Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
2
+ import type { Theme, BufferedMessage, QueueEditorAction } from "../types";
3
+ import { makeBox } from "../lib/render-helpers";
4
+
5
+ export interface QueueEditorState {
6
+ items: BufferedMessage[];
7
+ selected: number;
8
+ mode: "list" | "edit";
9
+ editInput: Input;
10
+ }
11
+
12
+ /**
13
+ * Create a new queue editor state.
14
+ * Clones the buffer items so edits don't affect the original until saved.
15
+ * Wires up the Input component's onSubmit/onEscape callbacks to manage mode transitions.
16
+ */
17
+ export function createQueueEditorState(items: BufferedMessage[]): QueueEditorState {
18
+ const editInput = new Input();
19
+ const state: QueueEditorState = {
20
+ items: items.map((m) => ({ ...m })),
21
+ selected: 0,
22
+ mode: "list",
23
+ editInput,
24
+ };
25
+
26
+ editInput.onSubmit = (value: string) => {
27
+ if (state.items.length === 0) {
28
+ state.mode = "list";
29
+ return;
30
+ }
31
+ const updated = value.trim();
32
+ if (updated.length > 0) {
33
+ state.items[state.selected].text = updated;
34
+ }
35
+ state.mode = "list";
36
+ };
37
+
38
+ editInput.onEscape = () => {
39
+ state.mode = "list";
40
+ };
41
+
42
+ return state;
43
+ }
44
+
45
+ /**
46
+ * Open the inline editor for the currently selected item.
47
+ */
48
+ function openEditor(state: QueueEditorState): void {
49
+ if (state.items.length === 0) return;
50
+ state.mode = "edit";
51
+ state.editInput.setValue(state.items[state.selected].text);
52
+ // Place cursor at end so appending extra context is frictionless.
53
+ state.editInput.handleInput("\u0005"); // Ctrl+E
54
+ }
55
+
56
+ /**
57
+ * Handle input for the queue editor overlay.
58
+ * In edit mode, delegates to the Input component.
59
+ * In list mode, processes navigation, reorder, toggle, delete, save, cancel.
60
+ * Returns an action for save/cancel, otherwise mutates state and returns undefined.
61
+ */
62
+ export function handleQueueEditorInput(
63
+ state: QueueEditorState,
64
+ data: string
65
+ ): QueueEditorAction | undefined {
66
+ if (state.items.length === 0) {
67
+ if (matchesKey(data, "return") || matchesKey(data, "escape")) {
68
+ return { type: "save", items: [] };
69
+ }
70
+ return;
71
+ }
72
+
73
+ if (state.mode === "edit") {
74
+ state.editInput.handleInput(data);
75
+ return;
76
+ }
77
+
78
+ // --- List mode ---
79
+
80
+ const moveUp =
81
+ data === "k" ||
82
+ data === "K" ||
83
+ matchesKey(data, "k") ||
84
+ matchesKey(data, Key.shift("k")) ||
85
+ data === "\u001b[1;2A";
86
+ const moveDown =
87
+ data === "j" ||
88
+ data === "J" ||
89
+ matchesKey(data, "j") ||
90
+ matchesKey(data, Key.shift("j")) ||
91
+ data === "\u001b[1;2B";
92
+
93
+ if (moveUp) {
94
+ if (state.selected > 0) {
95
+ const [item] = state.items.splice(state.selected, 1);
96
+ state.items.splice(state.selected - 1, 0, item);
97
+ state.selected--;
98
+ }
99
+ return;
100
+ }
101
+ if (moveDown) {
102
+ if (state.selected < state.items.length - 1) {
103
+ const [item] = state.items.splice(state.selected, 1);
104
+ state.items.splice(state.selected + 1, 0, item);
105
+ state.selected++;
106
+ }
107
+ return;
108
+ }
109
+ if (matchesKey(data, "up")) {
110
+ state.selected = Math.max(0, state.selected - 1);
111
+ return;
112
+ }
113
+ if (matchesKey(data, "down")) {
114
+ state.selected = Math.min(state.items.length - 1, state.selected + 1);
115
+ return;
116
+ }
117
+ if (matchesKey(data, "tab")) {
118
+ state.items[state.selected].mode =
119
+ state.items[state.selected].mode === "steer" ? "followUp" : "steer";
120
+ return;
121
+ }
122
+ if (
123
+ data === "e" ||
124
+ data === "E" ||
125
+ matchesKey(data, "e") ||
126
+ matchesKey(data, Key.shift("e"))
127
+ ) {
128
+ openEditor(state);
129
+ return;
130
+ }
131
+ if (
132
+ data === "d" ||
133
+ data === "D" ||
134
+ matchesKey(data, "delete") ||
135
+ matchesKey(data, "backspace")
136
+ ) {
137
+ state.items.splice(state.selected, 1);
138
+ state.selected = Math.min(state.selected, Math.max(0, state.items.length - 1));
139
+ return;
140
+ }
141
+ if (matchesKey(data, "return")) {
142
+ return { type: "save", items: state.items };
143
+ }
144
+ if (matchesKey(data, "escape")) {
145
+ return { type: "cancel" };
146
+ }
147
+ return;
148
+ }
149
+
150
+ /**
151
+ * Render the queue editor overlay.
152
+ * @param state - Current editor state
153
+ * @param width - Box width (rendered lines will be this wide)
154
+ * @param theme - Theme for styling
155
+ */
156
+ export function renderQueueEditor(
157
+ state: QueueEditorState,
158
+ width: number,
159
+ theme: Theme
160
+ ): string[] {
161
+ const innerW = width - 2;
162
+ const { row, emptyRow, divider, topBorder, bottomBorder } = makeBox(innerW, theme);
163
+
164
+ const lines: string[] = [];
165
+
166
+ lines.push(topBorder("Queue"));
167
+ lines.push(emptyRow());
168
+ lines.push(row(` ${theme.bold(theme.fg("accent", "📋 Queue Editor"))}`));
169
+ lines.push(row(` ${theme.fg("dim", `${state.items.length} queued ${state.items.length === 1 ? "message" : "messages"}`)}`));
170
+ lines.push(emptyRow());
171
+ lines.push(divider());
172
+
173
+ if (state.items.length === 0) {
174
+ lines.push(emptyRow());
175
+ lines.push(row(` ${theme.fg("muted", "Queue is empty")}`));
176
+ lines.push(emptyRow());
177
+ } else if (state.mode === "edit") {
178
+ const item = state.items[state.selected];
179
+ const modeTag =
180
+ item.mode === "steer"
181
+ ? theme.bold(theme.fg("warning", "⚡ STEER"))
182
+ : theme.bold(theme.fg("success", "📋 FOLLOW-UP"));
183
+
184
+ lines.push(row(` ${theme.bold(theme.fg("accent", "✎ Edit message"))} ${theme.fg("dim", `#${state.selected + 1}`)} ${modeTag}`));
185
+ lines.push(emptyRow());
186
+
187
+ for (const inputLine of state.editInput.render(Math.max(12, innerW - 4))) {
188
+ lines.push(row(` ${inputLine}`));
189
+ }
190
+
191
+ lines.push(emptyRow());
192
+ lines.push(
193
+ row(` ${theme.fg("muted", "Enter to save · Esc to cancel · Tip: append extra context at the end")}`)
194
+ );
195
+ } else {
196
+ lines.push(emptyRow());
197
+ for (let i = 0; i < state.items.length; i++) {
198
+ const item = state.items[i];
199
+ const isSel = i === state.selected;
200
+ const prefix = isSel ? theme.fg("accent", "▸") : theme.fg("dim", "·");
201
+ const indexTag = theme.fg("dim", `${String(i + 1).padStart(2, " ")}.`);
202
+ const modeTag =
203
+ item.mode === "steer"
204
+ ? theme.bold(theme.fg("warning", "STEER"))
205
+ : theme.bold(theme.fg("success", "FOLLOW"));
206
+ const textMaxW = Math.max(1, innerW - 25);
207
+ const text = truncateToWidth(item.text, textMaxW);
208
+ const textStyled = isSel ? theme.bold(theme.fg("accent", text)) : theme.fg("dim", text);
209
+
210
+ lines.push(row(` ${prefix} ${indexTag} ${modeTag} ${textStyled}`));
211
+ }
212
+ lines.push(emptyRow());
213
+ }
214
+
215
+ lines.push(divider());
216
+
217
+ const help =
218
+ state.mode === "edit"
219
+ ? `${theme.fg("muted", "enter")} ${theme.fg("dim", "save")} ${theme.fg("muted", "esc")} ${theme.fg("dim", "cancel edit")}`
220
+ : `${theme.fg("muted", "↑↓")} ${theme.fg("dim", "nav")} ${theme.fg("muted", "j/k")} ${theme.fg("dim", "move")} ${theme.fg("muted", "tab")} ${theme.fg("dim", "mode")} ${theme.fg("muted", "e")} ${theme.fg("dim", "edit")} ${theme.fg("muted", "d/del")} ${theme.fg("dim", "remove")} ${theme.fg("muted", "enter")} ${theme.fg("dim", "save")} ${theme.fg("muted", "esc")} ${theme.fg("dim", "close")}`;
221
+
222
+ lines.push(row(help));
223
+ lines.push(bottomBorder());
224
+
225
+ return lines;
226
+ }
@@ -0,0 +1,24 @@
1
+ export type Theme = Parameters<
2
+ Parameters<import("@mariozechner/pi-coding-agent").ExtensionUIContext["custom"]>[0]
3
+ >[1];
4
+
5
+ export type PickerMode = "steer" | "followUp";
6
+
7
+ export interface BufferedMessage {
8
+ text: string;
9
+ mode: PickerMode;
10
+ id: string;
11
+ }
12
+
13
+ let idCounter = 0;
14
+ export function nextId(): string {
15
+ return `qp-${Date.now()}-${idCounter++}`;
16
+ }
17
+
18
+ export type ModePickerAction =
19
+ | { type: "select"; mode: PickerMode }
20
+ | { type: "cancel" };
21
+
22
+ export type QueueEditorAction =
23
+ | { type: "save"; items: BufferedMessage[] }
24
+ | { type: "cancel" };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@kaiserlich-dev/pi-queue-picker",
3
+ "version": "1.0.0",
4
+ "description": "Pick between steering and follow-up when queuing messages in pi",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/kaiserlich-dev/pi-queue-picker"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "files": [
17
+ "extensions",
18
+ "!extensions/__tests__",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "pi": {
23
+ "extensions": [
24
+ "./extensions/queue-picker.ts"
25
+ ],
26
+ "image": "https://raw.githubusercontent.com/kaiserlich-dev/pi-queue-picker/main/assets/preview.png"
27
+ },
28
+ "peerDependencies": {
29
+ "@mariozechner/pi-coding-agent": "*",
30
+ "@mariozechner/pi-tui": "*"
31
+ },
32
+ "scripts": {
33
+ "publish:pi": "bash ./scripts/publish.sh",
34
+ "test:regression": "bash ./scripts/test-regression-steer-injection.sh"
35
+ },
36
+ "devDependencies": {
37
+ "tsx": "^4.21.0",
38
+ "typescript": "^5.9.3"
39
+ }
40
+ }