diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3f8816a97..9b021d8d6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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:` and `fallbacks:/*` chips as shortcuts. +- Added `/queue ` 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 diff --git a/packages/coding-agent/src/modes/components/custom-editor.test.ts b/packages/coding-agent/src/modes/components/custom-editor.test.ts index 18a5fd681..24c67cefb 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.test.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.test.ts @@ -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(); diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index bf144ff11..98394db89 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -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"); + } } } diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 3ccd61b87..e166b7add 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -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 { + 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 { + const splitMessages = splitQueuedMessages(text); + if (splitMessages.length === 0 && !options.images?.length) { + this.ctx.editor.clearDraft(); + this.ctx.showWarning("Usage: /queue (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 { let text = this.ctx.editor.getExpandedText().trim(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index cc968c862..dc4716dec 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -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 { + return this.#inputController.handleQueueCommand(message); + } + handleBtwCommand(question: string): Promise { return this.#btwController.start(question); } diff --git a/packages/coding-agent/src/modes/queue-input.ts b/packages/coding-agent/src/modes/queue-input.ts new file mode 100644 index 000000000..ee357f2fb --- /dev/null +++ b/packages/coding-agent/src/modes/queue-input.ts @@ -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> = { + 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]; +} diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 1f3d13c18..cf3dd39eb 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -377,6 +377,8 @@ export interface InteractiveModeContext { handleCtrlZ(): void; handleDequeue(): void; handleImagePaste(): Promise; + /** Queue a message for delivery only after the active agent turn would stop. */ + handleQueueCommand(message: string): Promise; handleBtwCommand(question: string): Promise; handleTanCommand(work: string): Promise; hasActiveBtw(): boolean; diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 447998a74..8a5e5776f 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -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)); } } diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 776fe7f13..e5a5711b9 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -326,6 +326,15 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ if (prompt) return { prompt }; }, }, + { + name: "queue", + description: "Queue a message for after the agent yields", + inlineHint: "", + allowArgs: true, + handleTui: async (command, runtime) => { + await runtime.ctx.handleQueueCommand(command.args); + }, + }, { name: "model", aliases: ["models"], diff --git a/packages/coding-agent/test/input-controller-skill-queue.test.ts b/packages/coding-agent/test/input-controller-skill-queue.test.ts index 6e4bfc07b..9c0beb7e9 100644 --- a/packages/coding-agent/test/input-controller-skill-queue.test.ts +++ b/packages/coding-agent/test/input-controller-skill-queue.test.ts @@ -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 () => { diff --git a/packages/coding-agent/test/input-controller-slash-history.test.ts b/packages/coding-agent/test/input-controller-slash-history.test.ts index 82f63edb3..9217e5aac 100644 --- a/packages/coding-agent/test/input-controller-slash-history.test.ts +++ b/packages/coding-agent/test/input-controller-slash-history.test.ts @@ -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), @@ -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) => 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"]); + }); }); diff --git a/packages/coding-agent/test/selector-settings-side-effects.test.ts b/packages/coding-agent/test/selector-settings-side-effects.test.ts index 547b46623..a44df7f35 100644 --- a/packages/coding-agent/test/selector-settings-side-effects.test.ts +++ b/packages/coding-agent/test/selector-settings-side-effects.test.ts @@ -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(); } diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d7700b4be..131733835 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index cd3e72ab0..7c53e13a3 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -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));