feat(coding-agent): introduced sequential message queueing and commands

- Implemented `/queue` command and `->`/`=>` shorthands to support deferred, sequential message processing.
- Added a robust parsing utility to handle various list-based queue inputs and automate yield management.
- Integrated visual decorations and state tracking to provide real-time feedback on queueing status.
- Enabled non-cursor line text decoration in the TUI to support dynamic queue header rendering and list numbering.
This commit is contained in:
can1357
2026-07-11 22:03:59 +02:00
parent 54bafa1cce
commit e7955ddf3c
14 changed files with 551 additions and 43 deletions
+1
View File
@@ -8,6 +8,7 @@
- Added model-oriented keys and wildcard entries to `retry.fallbackChains`: a `provider/model-id` key attaches a fallback chain to that exact model, a `provider/*` key covers every current or future model of a provider, and a `provider/*` chain entry keeps the failing model's id while swapping the provider (`google-antigravity/x` → `google/x`) — so fallbacks survive role and model reassignments without config edits. Keys resolve by specificity: exact model, then provider wildcard, then role, then `default`.
- Added fallback-chain editing to the /models Roles view: each role's `retry.fallbackChains` entries render as indented rows beneath it, `f` picks a fallback model to append, Enter on an entry replaces it, `x`/backspace removes it, and `[`/`]` (or shift+↑/↓) reorder the chain.
- Added model-keyed fallback management to the /models Roles view: model and `provider/*` chains render as a separate section below the roles (divider + "+ New fallback…" row for creating one by picking the protected model, then keying it by model or provider), with the same replace/remove/reorder editing as role chains; the model strip gains `fallbacks:<model>` and `fallbacks:<provider>/*` chips as shortcuts.
- Added `/queue <message>` plus `->` / `=>` composer shorthand for follow-up messages that wait until the agent yields. The shorthand opens a dim `Queueing` header and splits sequential numeric, Roman-numeral, or alphabetic lists into separately highlighted queue entries.
### Changed
@@ -1,8 +1,9 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import { CURSOR_MARKER } from "@oh-my-pi/pi-tui";
import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
import { $ } from "bun";
import { getDefaultPasteImageKeys } from "../../config/keybindings";
import { getEditorTheme, initTheme } from "../theme/theme";
import { getEditorTheme, initTheme, theme } from "../theme/theme";
import {
CustomEditor,
extractBracketedImagePastePaths,
@@ -78,6 +79,62 @@ describe("CustomEditor placeholder decoration", () => {
});
});
describe("CustomEditor queue shorthand decoration", () => {
beforeAll(async () => {
await initTheme();
});
it("reserves the first line as soon as either queue prefix is completed", () => {
for (const prefix of ["->", "=>"]) {
const editor = new CustomEditor(getEditorTheme());
editor.handleInput(prefix[0] ?? "");
expect(editor.getText()).toBe(prefix[0]);
editor.handleInput(prefix[1] ?? "");
expect(editor.getText()).toBe(`${prefix}\n`);
expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
editor.handleInput("\x7f");
expect(editor.getText()).toBe(`${prefix}\n`);
expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
}
});
it("renders the reserved line as a dim Queueing header", () => {
for (const prefix of ["->", "=>"]) {
const editor = new CustomEditor(getEditorTheme());
editor.setText(`${prefix}\nqueue this`);
expect(editor.decorateText(prefix)).toBe(theme.fg("dim", `Queueing ${theme.nav.selected}`));
editor.focused = true;
const rendered = editor.render(40).map(line => Bun.stripANSI(line.replace(CURSOR_MARKER, "")));
expect(rendered.some(line => line.includes(`Queueing ${theme.nav.selected}`))).toBe(true);
expect(rendered.every(line => Bun.stringWidth(line) === 40)).toBe(true);
expect(rendered.some(line => line.includes("queue this"))).toBe(true);
}
});
it("highlights dot and parenthesis markers only for detected queue lists", () => {
for (const [input, marker] of [
["=>\n1. first\n2. second", "1."],
["=>\n1) first\n2) second", "1)"],
]) {
const editor = new CustomEditor(getEditorTheme());
editor.setText(input);
expect(editor.decorateText(`${marker} first`).startsWith(theme.fg("accent", marker))).toBe(true);
}
const unfinished = new CustomEditor(getEditorTheme());
unfinished.setText("=>\n1. first\n2. second\n3. third\n4.");
expect(unfinished.decorateText("1. first").startsWith(theme.fg("accent", "1."))).toBe(true);
expect(unfinished.decorateText("4.").startsWith(theme.fg("accent", "4."))).toBe(true);
const editor = new CustomEditor(getEditorTheme());
editor.setText("=>\n1. first\n3. third");
expect(editor.decorateText("1. first")).toBe("1. first");
});
});
describe("CustomEditor bracketed path paste", () => {
it("leaves a pasted bare .png filename on the normal text path", () => {
expect(extractBracketedImagePastePaths(bracketedPaste("icon-photo-default.png"))).toBeUndefined();
@@ -6,7 +6,8 @@ import type { AppKeybinding } from "../../config/keybindings";
import { isSettingsInitialized, settings } from "../../config/settings";
import { imageReferenceHyperlink, PLACEHOLDER_REGEX, renderPlaceholders } from "../image-references";
import { hasMagicKeyword, highlightMagicKeywords } from "../magic-keywords";
import { fgOrPlain } from "../theme/theme";
import { isQueuedMessageList, parseQueueShorthand, QUEUE_LIST_MARKER_RE } from "../queue-input";
import { fgOrPlain, theme } from "../theme/theme";
type ConfigurableEditorAction = Extract<
AppKeybinding,
@@ -326,21 +327,41 @@ export class CustomEditor extends Editor {
* timer to request the next animation frame. Undefined when nobody is
* listening (tests, headless callers); the timer chain still self-cleans. */
#requestShimmerRepaint: (() => void) | undefined;
#queueDecorationText: string | undefined;
#queueShorthandActive = false;
#queueListActive = false;
/** Gradient-highlight the "ultrathink" / "orchestrate" / "workflowz" keywords as the user types
* them, skipping any occurrence inside code spans, fenced blocks, or XML sections. Also make
* pasted image placeholders visually distinct and hyperlink them once their blob file exists.
* When the editor is focused, the buffer contains a magic keyword, and `magicKeywords.enabled`
* is on, the gradient shifts every frame to produce a Claude-Code-style shimmer; each render
* schedules the next frame, so losing focus, deleting the keyword, or flipping the setting
* stops the animation on its own. The static glow itself runs even when shimmering is gated
* off, matching existing behavior for the editor and sent bubbles. */
/** Decorate magic keywords, attachments, and the queue-composer header/list markers.
* Queue shorthand reserves its first logical line as a dim `Queueing` label; sequential
* item markers use the accent color so separate follow-ups remain visible while composing. */
decorateText = (text: string): string => {
const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(this.getText());
const editorText = this.getText();
const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(editorText);
const phase = animated ? (Date.now() % CustomEditor.SHIMMER_PERIOD_MS) / CustomEditor.SHIMMER_PERIOD_MS : 0;
if (animated) this.#scheduleShimmerFrame();
if (this.#queueDecorationText !== editorText) {
this.#queueDecorationText = editorText;
const queueBody = parseQueueShorthand(editorText);
this.#queueShorthandActive = queueBody !== undefined;
this.#queueListActive = queueBody !== undefined && isQueuedMessageList(queueBody);
}
return renderPlaceholders(text, {
renderText: value => highlightMagicKeywords(value, undefined, phase),
renderText: value => {
const highlighted = highlightMagicKeywords(value, undefined, phase);
if (this.#queueShorthandActive && (value.startsWith("->") || value.startsWith("=>"))) {
const icon = typeof theme === "undefined" ? "➤" : theme.nav.selected;
return `${fgOrPlain("dim", `Queueing ${icon}`)}${highlighted.slice(2)}`;
}
if (this.#queueListActive) {
const markerMatch = QUEUE_LIST_MARKER_RE.exec(value);
if (markerMatch) {
const indent = markerMatch[1] ?? "";
const markerEnd = markerMatch[0].length;
return `${indent}${fgOrPlain("accent", value.slice(indent.length, markerEnd))}${highlighted.slice(markerEnd)}`;
}
}
return highlighted;
},
renderReference: (value, kind, index) =>
kind === "image"
? imageReferenceHyperlink(value, index, this.imageLinks, label =>
@@ -628,6 +649,7 @@ export class CustomEditor extends Editor {
this.#pendingInput.push(data);
return;
}
const hadBareQueuePrefix = this.getText() === "->" || this.getText() === "=>";
const kittyParsed = parseKittySequence(data);
if (kittyParsed && (kittyParsed.modifier & 64) !== 0 && this.onCapsLock) {
// Caps Lock is modifier bit 64
@@ -831,5 +853,14 @@ export class CustomEditor extends Editor {
// Pass to parent for normal handling
super.handleInput(data);
const cursor = this.getCursor();
if (
!hadBareQueuePrefix &&
(this.getText() === "->" || this.getText() === "=>") &&
cursor.line === 0 &&
cursor.col === 2
) {
this.insertText("\n");
}
}
}
@@ -13,6 +13,7 @@ import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-
import { expandEmoticons } from "../../modes/emoji-autocomplete";
import { materializeImageReferenceLinks, shiftImageMarkers } from "../../modes/image-references";
import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
import { parseQueueShorthand, splitQueuedMessages } from "../../modes/queue-input";
import { invokeSkillCommandFromText, isKnownSkillCommand } from "../../modes/skill-command";
import type { InteractiveModeContext } from "../../modes/types";
import manualContinuePrompt from "../../prompts/system/manual-continue.md" with { type: "text" };
@@ -650,6 +651,16 @@ export class InputController {
if (!text && !hasInputImages) return;
const queueBody = parseQueueShorthand(text);
if (queueBody !== undefined) {
await this.#queueForYield(queueBody, {
historyText: text,
images: inputImages,
imageLinks: inputImageLinks,
});
return;
}
// Handle built-in slash commands
if (text) {
const slashResult = await executeBuiltinSlashCommand(text, {
@@ -1117,6 +1128,126 @@ export class InputController {
}
}
/** Queue `/queue` input behind an active turn, or start it immediately when idle. */
async handleQueueCommand(text: string): Promise<void> {
const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
const imageLinks =
images && this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined;
await this.#queueForYield(text, { images, imageLinks });
}
async #queueForYield(
text: string,
options: {
historyText?: string;
images?: ImageContent[];
imageLinks?: (string | undefined)[];
},
): Promise<void> {
const splitMessages = splitQueuedMessages(text);
if (splitMessages.length === 0 && !options.images?.length) {
this.ctx.editor.clearDraft();
this.ctx.showWarning("Usage: /queue <message> (or start a prompt with -> / =>)");
return;
}
const messages = splitMessages.length > 0 ? splitMessages : [""];
const originalDraft = this.ctx.editor.getText();
const images = options.images?.length ? [...options.images] : undefined;
const imageLinks = options.imageLinks
? [...options.imageLinks]
: images
? images.map(() => undefined)
: undefined;
this.ctx.editor.clearDraft(options.historyText);
if (this.ctx.session.isCompacting) {
for (let index = 0; index < messages.length; index++) {
this.ctx.compactionQueuedMessages.push({
text: messages[index] ?? "",
mode: "followUp",
images: index === 0 ? images : undefined,
});
}
this.ctx.updatePendingMessagesDisplay();
this.ctx.showStatus(
messages.length === 1
? "Queued message for after compaction"
: `Queued ${messages.length} messages for after compaction`,
);
this.ctx.ui.requestRender();
return;
}
const startImmediately = !this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount === 0;
let queuedCount = 0;
try {
if (startImmediately && this.ctx.onInputCallback) {
const first = messages[0] ?? "";
const submission = this.ctx.startPendingSubmission({
text: first,
images,
imageLinks,
streamingBehavior: "followUp",
});
this.ctx.onInputCallback(submission);
queuedCount = 1;
}
while (queuedCount < messages.length) {
const message = messages[queuedCount] ?? "";
const queuedImages = queuedCount === 0 ? images : undefined;
await this.ctx.withLocalSubmission(
message,
async () => {
if (startImmediately && queuedCount === 0) {
await this.ctx.session.prompt(message, {
images: queuedImages,
streamingBehavior: "followUp",
});
} else {
await this.ctx.session.followUp(message, queuedImages);
}
},
{ imageCount: queuedImages?.length ?? 0 },
);
queuedCount++;
}
} catch (error) {
if (queuedCount === 0) {
this.ctx.editor.setText(originalDraft);
if (images) {
this.ctx.editor.pendingImages = images;
this.ctx.editor.pendingImageLinks = imageLinks ?? images.map(() => undefined);
this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks;
}
} else {
const remaining = messages.slice(queuedCount);
const restored =
remaining.length === 1
? `=> ${remaining[0]}`
: `=>\n${remaining
.map((message, index) => `${index + 1}. ${message.replaceAll("\n", "\n ")}`)
.join("\n")}`;
this.ctx.editor.setText(restored);
}
this.ctx.showError(error instanceof Error ? error.message : String(error));
}
this.ctx.updatePendingMessagesDisplay();
if (queuedCount === messages.length) {
this.ctx.showStatus(
startImmediately
? queuedCount === 1
? "Sent queued message"
: `Sent first message; queued ${queuedCount - 1} for later yields`
: queuedCount === 1
? "Queued message for when the agent yields"
: `Queued ${queuedCount} messages for when the agent yields`,
);
}
this.ctx.ui.requestRender();
}
/** Send editor text as a follow-up message (queued behind current stream). */
async handleFollowUp(): Promise<void> {
let text = this.ctx.editor.getExpandedText().trim();
@@ -4287,6 +4287,11 @@ export class InteractiveMode implements InteractiveModeContext {
return this.#inputController.handleImagePaste();
}
/** Queue slash-command input behind the active turn. */
handleQueueCommand(message: string): Promise<void> {
return this.#inputController.handleQueueCommand(message);
}
handleBtwCommand(question: string): Promise<void> {
return this.#btwController.start(question);
}
@@ -0,0 +1,132 @@
const QUEUE_PREFIXES: readonly string[] = ["->", "=>"];
/** Prefix matcher shared by queue-list parsing and editor highlighting. */
export const QUEUE_LIST_MARKER_RE = /^([\t ]*)(\d+|[A-Za-z]+)([.)])(?=[\t ]|$)/;
const CANONICAL_ROMAN_RE = /^(?=[MDCLXVI])M{0,3}(?:CM|CD|D?C{0,3})(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3})$/i;
interface EnumeratedItem {
line: number;
indent: string;
marker: string;
punctuation: string;
content: string;
}
interface EnumeratedList {
source: string;
lines: string[];
items: EnumeratedItem[];
}
/** Extract the message body from the `->` / `=>` yield-queue shorthand. */
export function parseQueueShorthand(text: string): string | undefined {
const prefix = QUEUE_PREFIXES.find(candidate => text.startsWith(candidate));
return prefix ? text.slice(prefix.length).trim() : undefined;
}
function parseEnumeratedItem(line: string, lineIndex: number): EnumeratedItem | undefined {
const match = QUEUE_LIST_MARKER_RE.exec(line);
if (!match) return undefined;
const [matched, indent, marker, punctuation] = match;
if (indent === undefined || marker === undefined || punctuation === undefined) return undefined;
return { line: lineIndex, indent, marker, punctuation, content: line.slice(matched.length).trimStart() };
}
function decodeDecimal(marker: string): number | undefined {
if (!/^\d+$/.test(marker)) return undefined;
const value = Number(marker);
return Number.isSafeInteger(value) ? value : undefined;
}
function decodeRoman(marker: string): number | undefined {
if (!CANONICAL_ROMAN_RE.test(marker)) return undefined;
const values: Readonly<Record<string, number>> = {
I: 1,
V: 5,
X: 10,
L: 50,
C: 100,
D: 500,
M: 1000,
};
const upper = marker.toUpperCase();
let value = 0;
for (let index = 0; index < upper.length; index++) {
const current = values[upper[index] ?? ""];
if (current === undefined) return undefined;
const next = values[upper[index + 1] ?? ""] ?? 0;
value += current < next ? -current : current;
}
return value;
}
function decodeAlpha(marker: string): number | undefined {
if (!/^[A-Za-z]+$/.test(marker)) return undefined;
let value = 0;
for (const char of marker.toUpperCase()) {
value = value * 26 + char.charCodeAt(0) - 64;
if (!Number.isSafeInteger(value)) return undefined;
}
return value;
}
function isSequential(markers: readonly string[], decode: (marker: string) => number | undefined): boolean {
let previous = decode(markers[0] ?? "");
if (previous === undefined) return false;
for (let index = 1; index < markers.length; index++) {
const current = decode(markers[index] ?? "");
if (current === undefined || current !== previous + 1) return false;
previous = current;
}
return true;
}
function isEnumeratedSequence(items: readonly EnumeratedItem[]): boolean {
const markers = items.map(item => item.marker);
if (markers.every(marker => /^\d+$/.test(marker))) return isSequential(markers, decodeDecimal);
if (
!markers.every(marker => marker === marker.toUpperCase()) &&
!markers.every(marker => marker === marker.toLowerCase())
) {
return false;
}
return isSequential(markers, decodeRoman) || isSequential(markers, decodeAlpha);
}
function parseEnumeratedList(text: string): EnumeratedList | undefined {
const source = text.trim();
if (!source) return undefined;
const lines = source.split(/\r?\n/);
const first = parseEnumeratedItem(lines[0] ?? "", 0);
if (!first) return undefined;
const items = [first];
for (let lineIndex = 1; lineIndex < lines.length; lineIndex++) {
const item = parseEnumeratedItem(lines[lineIndex] ?? "", lineIndex);
if (item?.indent === first.indent) items.push(item);
}
if (items.length < 2 || items.some(item => item.punctuation !== first.punctuation) || !isEnumeratedSequence(items)) {
return undefined;
}
return { source, lines, items };
}
/** Whether text currently forms a sequential queue list, including an unfinished trailing item. */
export function isQueuedMessageList(text: string): boolean {
return parseEnumeratedList(text) !== undefined;
}
/** Split a sequential numeric, Roman-numeral, or alphabetic list into queue entries. */
export function splitQueuedMessages(text: string): string[] {
const list = parseEnumeratedList(text);
if (!list) {
const source = text.trim();
return source ? [source] : [];
}
const messages = list.items.map((item, index) => {
const nextLine = list.items[index + 1]?.line ?? list.lines.length;
return [item.content, ...list.lines.slice(item.line + 1, nextLine)].join("\n").trim();
});
while (messages.at(-1) === "") messages.pop();
return messages.length > 0 && messages.every(Boolean) ? messages : [list.source];
}
+2
View File
@@ -377,6 +377,8 @@ export interface InteractiveModeContext {
handleCtrlZ(): void;
handleDequeue(): void;
handleImagePaste(): Promise<boolean>;
/** Queue a message for delivery only after the active agent turn would stop. */
handleQueueCommand(message: string): Promise<void>;
handleBtwCommand(question: string): Promise<void>;
handleTanCommand(work: string): Promise<void>;
hasActiveBtw(): boolean;
@@ -46,6 +46,7 @@ import {
type SkillPromptDetails,
} from "../../session/messages";
import type { SessionContext } from "../../session/session-context";
import { replaceTabs } from "../../tools/render-utils";
import { buildSkillCommandPrompt, invokeSkillCommandFromText, isKnownSkillCommand } from "../skill-command";
import { createAssistantMessageComponent } from "./interactive-context-helpers";
import {
@@ -677,35 +678,33 @@ export class UiHelpers {
this.ctx.pendingMessagesContainer.disposeChildren();
const queuedMessages = this.ctx.viewSession.getQueuedMessages() as QueuedMessages;
const steeringMessages: Array<{ message: string; label: string }> = [];
for (const message of queuedMessages.steering) {
steeringMessages.push({ message, label: "Steer" });
}
const steeringMessages = [...queuedMessages.steering];
for (const entry of this.ctx.compactionQueuedMessages as CompactionQueuedMessage[]) {
if (entry.mode === "steer") {
steeringMessages.push({ message: entry.text, label: "Steer" });
}
if (entry.mode === "steer") steeringMessages.push(entry.text);
}
const followUpMessages: Array<{ message: string; label: string }> = [];
for (const message of queuedMessages.followUp) {
followUpMessages.push({ message, label: "Follow-up" });
}
const followUpMessages = [...queuedMessages.followUp];
for (const entry of this.ctx.compactionQueuedMessages as CompactionQueuedMessage[]) {
if (entry.mode === "followUp") {
followUpMessages.push({ message: entry.text, label: "Follow-up" });
}
if (entry.mode === "followUp") followUpMessages.push(entry.text);
}
const allMessages = [...steeringMessages, ...followUpMessages];
if (allMessages.length > 0) {
const groups = [
{ label: "Steering", messages: steeringMessages },
{ label: "After yield", messages: followUpMessages },
].filter(group => group.messages.length > 0);
if (groups.length > 0) {
this.ctx.pendingMessagesContainer.addChild(new Spacer(1));
for (const entry of allMessages) {
const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
for (const group of groups) {
const heading = theme.fg("muted", `${group.label}${theme.sep.dot}${group.messages.length}`);
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(heading, 1, 0));
for (let index = 0; index < group.messages.length; index++) {
const message = replaceTabs(group.messages[index] ?? "").replace(/\r?\n/g, " ↵ ");
const queuedText = theme.fg("dim", ` ${index + 1}. ${message}`);
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
}
}
const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up";
const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to edit`);
const hintText = theme.fg("dim", ` ${theme.tree.hook} ${dequeueKey} to edit`);
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
}
}
@@ -326,6 +326,15 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
if (prompt) return { prompt };
},
},
{
name: "queue",
description: "Queue a message for after the agent yields",
inlineHint: "<message>",
allowArgs: true,
handleTui: async (command, runtime) => {
await runtime.ctx.handleQueueCommand(command.args);
},
},
{
name: "model",
aliases: ["models"],
@@ -698,8 +698,33 @@ describe("UiHelpers / InputController against derived queued custom display", ()
const uiHelpers = new UiHelpers(ctx);
uiHelpers.updatePendingMessagesDisplay();
const rendered = pendingMessagesContainer.render(120).join("\n");
expect(rendered).toMatch(/Steer: \/skill:test-skill arg1 arg2/);
const rendered = Bun.stripANSI(pendingMessagesContainer.render(120).join("\n"));
expect(rendered).toContain("Steering · 1");
expect(rendered).toContain("1. /skill:test-skill arg1 arg2");
expect(rendered).not.toContain("Steer:");
});
it("groups yield follow-ups under one heading", async () => {
fixture = await createRealSession();
const { session } = fixture;
for (const text of ["inspect types", "run tests", "summarize"]) {
session.agent.followUp({
role: "user",
content: text,
attribution: "user",
timestamp: Date.now(),
});
}
const { ctx, pendingMessagesContainer } = createStubInteractiveModeContextForUiHelpers(session);
new UiHelpers(ctx).updatePendingMessagesDisplay();
const rendered = Bun.stripANSI(pendingMessagesContainer.render(120).join("\n"));
expect(rendered).toContain("After yield · 3");
expect(rendered).toContain("1. inspect types");
expect(rendered).toContain("2. run tests");
expect(rendered).toContain("3. summarize");
expect(rendered).not.toContain("Follow-up:");
});
it("restores the compact slash form into the editor and clears the queue", async () => {
@@ -1,5 +1,7 @@
import { describe, expect, it, vi } from "bun:test";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller";
import { isQueuedMessageList, splitQueuedMessages } from "@oh-my-pi/pi-coding-agent/modes/queue-input";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
// Drives the real editor submit handler through the builtin slash dispatch
@@ -8,9 +10,12 @@ import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/typ
// executeBuiltinSlashCommand and the controller returned before any
// addToHistory call. The fix centralizes recording after dispatch, with a
// secret filter (shouldSkipHistory) for credential-bearing commands.
function makeCtx() {
function makeCtx(isStreaming = false) {
const addToHistory = vi.fn();
const handleMCPCommand = vi.fn(async () => {});
const followUp = vi.fn(async (_text: string, _images?: ImageContent[]) => {});
const steer = vi.fn(async (_text: string, _images?: ImageContent[]) => {});
const onInputCallback = vi.fn();
let text = "";
const editor = {
onSubmit: undefined as undefined | ((t: string) => Promise<void>),
@@ -19,34 +24,64 @@ function makeCtx() {
text = t;
},
addToHistory,
pendingImages: [] as unknown[],
pendingImageLinks: [] as unknown[],
pendingImages: [] as ImageContent[],
pendingImageLinks: [] as (string | undefined)[],
imageLinks: undefined as (string | undefined)[] | undefined,
clearDraft(historyText?: string) {
if (historyText !== undefined) addToHistory(historyText);
text = "";
this.imageLinks = undefined;
this.pendingImages = [];
this.pendingImageLinks = [];
},
};
const ctx = {
editor,
session: {
isStreaming: false,
isStreaming,
isCompacting: false,
queuedMessageCount: 0,
extensionRunner: undefined,
followUp,
steer,
},
focusedAgentId: undefined,
collabGuest: undefined,
handleHotkeysCommand: vi.fn(),
handleMCPCommand,
showStatus: vi.fn(),
onInputCallback,
startPendingSubmission: (input: {
text: string;
images?: ImageContent[];
imageLinks?: (string | undefined)[];
customType?: string;
display?: boolean;
streamingBehavior?: "steer" | "followUp";
}) => ({ ...input, cancelled: false, started: false }),
ui: { requestRender: vi.fn() },
compactionQueuedMessages: [],
withLocalSubmission: async (_text: string, fn: () => Promise<unknown>) => fn(),
updatePendingMessagesDisplay: vi.fn(),
showWarning: vi.fn(),
showError: vi.fn(),
} as unknown as InteractiveModeContext;
return { ctx, editor, addToHistory, handleMCPCommand };
return {
ctx,
editor,
addToHistory,
followUp,
steer,
onInputCallback,
handleMCPCommand,
showStatus: ctx.showStatus,
};
}
function controllerFor(ctx: InteractiveModeContext) {
const controller = new InputController(ctx);
controller.setupEditorSubmitHandler();
ctx.handleQueueCommand = message => controller.handleQueueCommand(message);
return controller;
}
@@ -81,4 +116,74 @@ describe("input controller — slash command history (#3148)", () => {
// ...but the secret-bearing text is kept out of recallable history.
expect(addToHistory).not.toHaveBeenCalled();
});
it("routes /queue through the yield-only follow-up queue while streaming", async () => {
const { ctx, editor, addToHistory, followUp, showStatus } = makeCtx(true);
controllerFor(ctx);
editor.setText("/queue inspect the final result");
await editor.onSubmit?.("/queue inspect the final result");
expect(followUp).toHaveBeenCalledWith("inspect the final result", undefined);
expect(addToHistory).toHaveBeenCalledWith("/queue inspect the final result");
expect(showStatus).toHaveBeenCalledWith("Queued message for when the agent yields");
});
it("starts the first queued item immediately when the session is idle", async () => {
const { ctx, editor, followUp, steer, onInputCallback, showStatus } = makeCtx();
controllerFor(ctx);
const input = "=>\n1. inspect types\n2. run focused tests\n3. summarize failures";
editor.setText(input);
await editor.onSubmit?.(input);
expect(onInputCallback).toHaveBeenCalledWith(
expect.objectContaining({ text: "inspect types", streamingBehavior: "followUp" }),
);
expect(steer).not.toHaveBeenCalled();
expect(followUp.mock.calls.map(call => call[0])).toEqual(["run focused tests", "summarize failures"]);
expect(showStatus).toHaveBeenCalledWith("Sent first message; queued 2 for later yields");
});
it("queues an enumerated shorthand prompt as separate ordered follow-ups", async () => {
const { ctx, editor, addToHistory, followUp, showStatus } = makeCtx(true);
controllerFor(ctx);
const input = "=>\n1. inspect types\n2. run focused tests\n3. summarize failures";
editor.setText(input);
await editor.onSubmit?.(input);
expect(followUp.mock.calls.map(call => call[0])).toEqual([
"inspect types",
"run focused tests",
"summarize failures",
]);
expect(addToHistory).toHaveBeenCalledWith(input);
expect(showStatus).toHaveBeenCalledWith("Queued 3 messages for when the agent yields");
});
});
describe("yield queue list parsing", () => {
it("recognizes numeric, Roman, and alphabetic sequences", () => {
const expected = ["first", "second", "third"];
for (const input of [
"1. first\n2. second\n3. third",
"I. first\nII. second\nIII. third",
"i. first\nii. second\niii. third",
"A. first\nB. second\nC. third",
"a) first\nb) second\nc) third",
]) {
expect(splitQueuedMessages(input)).toEqual(expected);
}
});
it("keeps continuation lines together and rejects non-sequential markers", () => {
expect(splitQueuedMessages("1. first line\n more detail\n2. second")).toEqual([
"first line\n more detail",
"second",
]);
expect(splitQueuedMessages("1. first\n3. third")).toEqual(["1. first\n3. third"]);
expect(isQueuedMessageList("1. first\n2. second\n3. third\n4.")).toBe(true);
expect(splitQueuedMessages("1. first\n2. second\n3. third\n4.")).toEqual(["first", "second", "third"]);
});
});
@@ -128,7 +128,7 @@ describe("selector setting side effects", () => {
expect(showError).not.toHaveBeenCalled();
expect(settings.get("retry.fallbackChains")).toEqual({ default: ["test/retry-fallback-model"] });
expect(showStatus).toHaveBeenCalledWith("Default fallback model: test/retry-fallback-model");
expect(showStatus).toHaveBeenCalledWith("Default fallbacks: test/retry-fallback-model");
} finally {
hub.dispose();
}
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added support for width-changing editor text decorators on standalone presentation lines, with decorated output safely truncated to the available content width.
## [16.4.5] - 2026-07-11
### Added
+10 -3
View File
@@ -387,9 +387,9 @@ export class Editor implements Component, Focusable {
cursorOverride: string | undefined;
/** Display width of the cursorOverride glyph (needed because override may contain ANSI escapes). */
cursorOverrideWidth: number | undefined;
/** Optional hook that styles displayed input text with zero-width ANSI escapes.
* MUST preserve visible width (may only add SGR codes, never glyphs). Applied per
* layout line to the user-text segments — never to the cursor glyph or inline hint. */
/** Optional hook that decorates displayed user text after source-text layout.
* Width-changing output is allowed on lines without the cursor; it is truncated
* to the content width rather than reflowed. Cursor glyphs and inline hints are excluded. */
decorateText: ((text: string) => string) | undefined;
#promptGutter: string | undefined;
@@ -1001,6 +1001,13 @@ export class Editor implements Component, Focusable {
if (!decorated) {
displayText = this.#decorate(displayText);
}
if (!hasCursor) {
displayWidth = visibleWidth(displayText);
if (displayWidth > lineContentWidth) {
displayText = truncateToWidth(displayText, lineContentWidth);
displayWidth = visibleWidth(displayText);
}
}
const linePad = padding(Math.max(0, lineContentWidth - displayWidth));