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:
@@ -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];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user