refactor(coding-agent): consolidated editor state and unify transcript rendering

- Centralized draft state and image management by migrating fields from context to the CustomEditor component.
- Standardized transcript row construction by introducing shared helpers for background jobs, IRC traffic, and file mentions.
- Refactored redundant UI logic and helper functions into reusable utility modules to streamline message submission and component rendering.
- Standardized event handler types by consolidating lifecycle definitions into a shared module while maintaining public API stability.
This commit is contained in:
can1357
2026-06-22 06:11:57 +02:00
parent c204d1b30c
commit 93db34b0d5
25 changed files with 419 additions and 390 deletions
+3
View File
@@ -1,8 +1,11 @@
# Changelog
## [Unreleased]
### Changed
- Refactored internal draft state to consolidate images and text into the editor component
- Unified transcript rendering logic for background jobs, IRC traffic, and file mentions
- Unified subprocess lifecycle management for mnemopi, speech, tiny-model, and TTS workers
- Softened the system-prompt and tool-prompt guidance that hard-forbade reaching for shell equivalents of dedicated tools (`grep`/`rg`, `sed`/`perl -i`, `cat`/`head`/`tail`, `find`/`fd`). The "Specialized Tool Priority" section, the `bash`/`search`/`find`/`read`/`replace` tool prompts, and the FORBIDDEN/NEVER framing now state the dedicated tools as the preferred default rather than an absolute prohibition, so the agent no longer fights itself when a quick shell command is the right call. The opt-in `bashInterceptor` (default off) still hard-blocks these commands for users who enable it.
- Unified subprocess lifecycle management for mnemopi, speech, tiny-model, and TTS workers
+1 -1
View File
@@ -400,7 +400,7 @@ export class DapClient {
framer.push(Buffer.from(value));
// Drain every complete message currently buffered.
for (const messageText of framer.drain((headerText) => {
for (const messageText of framer.drain(headerText => {
// Non-protocol bytes (e.g. an adapter printing to stdout).
// Drop past the bogus terminator and resync instead of
// stalling on the same junk header forever.
@@ -10,6 +10,7 @@ import type { Settings } from "../../config/settings";
import type { MemoryRuntimeContext } from "../../memory-backend";
import { type Theme, theme } from "../../modes/theme/theme";
import type { SessionManager } from "../../session/session-manager";
import type { BranchHandler, NavigateTreeHandler, NewSessionHandler } from "../session-handler-types";
import { createExtensionModelQuery } from "./model-api";
import type {
AfterProviderResponseEvent,
@@ -141,17 +142,9 @@ type RunnerEmitResult<TEvent extends RunnerEmitEvent> = TEvent extends { type: "
? SessionStopEventResult | undefined
: undefined;
export type NewSessionHandler = (options?: {
parentSession?: string;
setup?: (sessionManager: SessionManager) => Promise<void>;
}) => Promise<{ cancelled: boolean }>;
export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>;
export type NavigateTreeHandler = (
targetId: string,
options?: { summarize?: boolean },
) => Promise<{ cancelled: boolean }>;
// Session-lifecycle handler types live once in session-handler-types (imported
// above for local use); re-exported here to keep this module's public API stable.
export type { BranchHandler, NavigateTreeHandler, NewSessionHandler };
export type SwitchSessionHandler = (sessionPath: string) => Promise<{ cancelled: boolean }>;
@@ -11,7 +11,6 @@ import { loadCapability } from "../../discovery";
// Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe.
import * as PiCodingAgent from "../../index";
import type { HookMessage } from "../../session/messages";
import type { SessionManager } from "../../session/session-manager";
import * as typebox from "../typebox";
import { resolvePath } from "../utils";
import { execCommand } from "./runner";
@@ -35,26 +34,9 @@ export type SendMessageHandler = <T = unknown>(
*/
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
/**
* New session handler type for ctx.newSession() in HookCommandContext.
*/
export type NewSessionHandler = (options?: {
parentSession?: string;
setup?: (sessionManager: SessionManager) => Promise<void>;
}) => Promise<{ cancelled: boolean }>;
/**
* Branch handler type for ctx.branch() in HookCommandContext.
*/
export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>;
/**
* Navigate tree handler type for ctx.navigateTree() in HookCommandContext.
*/
export type NavigateTreeHandler = (
targetId: string,
options?: { summarize?: boolean },
) => Promise<{ cancelled: boolean }>;
// Session-lifecycle handler types live once in session-handler-types; re-exported
// here because hooks/runner.ts imports them from this module.
export type { BranchHandler, NavigateTreeHandler, NewSessionHandler } from "../session-handler-types";
/**
* Registered handlers for a loaded hook.
@@ -0,0 +1,21 @@
/**
* Session-lifecycle handler types shared by the extension runner and the hook
* loader/runner. Both surfaces wire the same new-session / branch / navigate
* handlers into their command contexts; this is the single source of truth.
*/
import type { SessionManager } from "../session/session-manager";
/** Handler for `ctx.newSession()` — creates (and optionally seeds) a session. */
export type NewSessionHandler = (options?: {
parentSession?: string;
setup?: (sessionManager: SessionManager) => Promise<void>;
}) => Promise<{ cancelled: boolean }>;
/** Handler for `ctx.branch()` — branches from a transcript entry. */
export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>;
/** Handler for `ctx.navigateTree()` — navigates the session tree. */
export type NavigateTreeHandler = (
targetId: string,
options?: { summarize?: boolean },
) => Promise<{ cancelled: boolean }>;
+1 -1
View File
@@ -222,7 +222,7 @@ async function startMessageReader(client: LspClient): Promise<void> {
framer.push(Buffer.from(value));
// Drain every complete message currently buffered.
for (const messageText of framer.drain((headerText) => {
for (const messageText of framer.drain(headerText => {
// Non-protocol bytes on stdout (e.g. a wrapper script printing).
// Drop past the bogus terminator and resync instead of stalling
// on the same junk header forever.
@@ -13,8 +13,7 @@
*/
import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
import type { Usage } from "@oh-my-pi/pi-ai";
import { Text, type TUI } from "@oh-my-pi/pi-tui";
import { formatBytes, formatDuration } from "@oh-my-pi/pi-utils";
import type { TUI } from "@oh-my-pi/pi-tui";
import type { AdvisorMessageDetails } from "../../advisor";
import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol";
import { settings } from "../../config/settings";
@@ -22,16 +21,20 @@ import type { MessageRenderer } from "../../extensibility/extensions/types";
import {
BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
type CustomMessage,
isSilentAbort,
LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
resolveAbortLabel,
SKILL_PROMPT_MESSAGE_TYPE,
type SkillPromptDetails,
} from "../../session/messages";
import type { SessionMessageEntry } from "../../session/session-entries";
import { createIrcMessageCard } from "../../tools/irc";
import { canonicalizeMessage } from "../../utils/thinking-display";
import { theme } from "../theme/theme";
import {
assistantHasVisibleContent,
buildAsyncResultBlock,
buildFileMentionBlock,
buildIrcMessageCard,
normalizeToolArgs,
resolveAssistantErrorMessage,
} from "../utils/transcript-render-helpers";
import { createAdvisorMessageCard } from "./advisor-message";
import { AssistantMessageComponent } from "./assistant-message";
import { createBackgroundTanDispatchBlock } from "./background-tan-message";
@@ -49,7 +52,7 @@ import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./lat
import { ReadToolGroupComponent, readArgsHaveTarget, readArgsTargetInternalUrl } from "./read-tool-group";
import { SkillMessageComponent } from "./skill-message";
import { ToolExecutionComponent } from "./tool-execution";
import { TranscriptBlock, TranscriptContainer } from "./transcript-container";
import { TranscriptContainer } from "./transcript-container";
import { createUsageRowBlock } from "./usage-row";
import { UserMessageComponent } from "./user-message";
@@ -218,27 +221,9 @@ export class ChatTranscriptBuilder {
break;
}
case "fileMention": {
const block = new TranscriptBlock();
for (const file of message.files) {
let suffix: string;
if (file.skippedReason === "tooLarge") {
const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size";
suffix = `(skipped: ${size})`;
} else {
suffix = file.image
? "(image)"
: file.lineCount === undefined
? "(unknown lines)"
: `(${file.lineCount} lines)`;
}
const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg(
"accent",
file.path,
)} ${theme.fg("dim", suffix)}`;
// Indent one column to match the transcript's other rows (the viewer renders
// body rows without an outer gutter; rows own their left pad).
block.addChild(new Text(text, 1, 0));
}
// Indent one column to match the transcript's other rows (the viewer renders
// body rows without an outer gutter; rows own their left pad).
const block = buildFileMentionBlock(message.files, 1);
if (block.children.length > 0) this.container.addChild(block);
break;
}
@@ -266,24 +251,14 @@ export class ChatTranscriptBuilder {
this.#lastAssistantUsage = message.usage;
}
const hasVisibleAssistantContent = message.content.some(
content =>
(content.type === "text" && canonicalizeMessage(content.text)) ||
(content.type === "thinking" && canonicalizeMessage(content.thinking)),
);
const hasVisibleAssistantContent = assistantHasVisibleContent(message);
if (hasVisibleAssistantContent) {
// New visible turn content closes the current read run (mirrors rebuild).
this.#readGroup?.seal();
this.#readGroup = null;
}
const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage);
const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error");
const errorMessage = hasErrorStop
? message.stopReason === "aborted"
? resolveAbortLabel(message.errorMessage)
: message.errorMessage || "Error"
: null;
const { hasErrorStop, errorMessage } = resolveAssistantErrorMessage(message);
for (const content of message.content) {
if (content.type !== "toolCall") continue;
@@ -303,10 +278,7 @@ export class ChatTranscriptBuilder {
content.id,
);
} else {
const normalizedArgs =
content.arguments && typeof content.arguments === "object" && !Array.isArray(content.arguments)
? (content.arguments as Record<string, unknown>)
: {};
const normalizedArgs = normalizeToolArgs(content.arguments);
this.#readArgs.set(content.id, normalizedArgs);
}
continue;
@@ -373,42 +345,7 @@ export class ChatTranscriptBuilder {
#appendCustomMessage(message: Extract<AgentMessage, { role: "custom" | "hookMessage" }>): void {
if (!message.display) return;
if (message.customType === "async-result") {
const details = (
message as CustomMessage<{
jobId?: string;
type?: "bash" | "task";
label?: string;
durationMs?: number;
jobs?: Array<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number }>;
}>
).details;
const jobs =
details?.jobs && details.jobs.length > 0
? details.jobs
: [
{
jobId: details?.jobId,
type: details?.type,
label: details?.label,
durationMs: details?.durationMs,
},
];
const block = new TranscriptBlock();
for (const job of jobs) {
const jobId = job.jobId ?? "unknown";
const typeLabel = job.type ? `[${job.type}]` : "[job]";
const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined;
const line = [
theme.fg("success", `${theme.status.done} Background job completed`),
theme.fg("dim", typeLabel),
theme.fg("accent", jobId),
duration ? theme.fg("dim", `(${duration})`) : undefined,
]
.filter(Boolean)
.join(" ");
block.addChild(new Text(line, 1, 0));
}
this.container.addChild(block);
this.container.addChild(buildAsyncResultBlock(message));
return;
}
if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) {
@@ -433,28 +370,7 @@ export class ChatTranscriptBuilder {
message.customType === "irc:autoreply" ||
message.customType === "irc:relay"
) {
const details = (
message as CustomMessage<{ from?: string; to?: string; message?: string; body?: string; replyTo?: string }>
).details;
const kind =
message.customType === "irc:incoming"
? ("incoming" as const)
: message.customType === "irc:autoreply"
? ("autoreply" as const)
: ("relay" as const);
const card = createIrcMessageCard(
{
kind,
from: details?.from,
to: details?.to,
body: kind === "incoming" ? details?.message : details?.body,
replyTo: details?.replyTo,
timestamp: message.timestamp,
},
() => this.#expanded,
theme,
);
this.container.addChild(card);
this.container.addChild(buildIrcMessageCard(message, () => this.#expanded));
return;
}
if (message.customType === "advisor") {
@@ -1,3 +1,4 @@
import type { ImageContent } from "@oh-my-pi/pi-ai";
import { addKeyAliases, canonicalKeyId, Editor, type KeyId, parseKey, parseKittySequence } from "@oh-my-pi/pi-tui";
import type { AppKeybinding } from "../../config/keybindings";
import { isSettingsInitialized, settings } from "../../config/settings";
@@ -163,6 +164,24 @@ export function extractBracketedImagePastePath(data: string): string | undefined
export class CustomEditor extends Editor {
imageLinks?: readonly (string | undefined)[];
/** Draft images pasted into the composer, consumed on submit. Co-located with
* {@link imageLinks} so every piece of draft-image state lives on the editor. */
pendingImages: ImageContent[] = [];
/** Per-image source links (file:// targets) parallel to {@link pendingImages};
* `undefined` entries are images without a backing reference yet. */
pendingImageLinks: (string | undefined)[] = [];
/** Clear the composer draft: optionally commit `historyText` to history, then
* reset the editor text and all pending draft-image state. The shared tail of
* every "message submitted" path; pass no argument for a plain discard. */
clearDraft(historyText?: string): void {
if (historyText !== undefined) this.addToHistory(historyText);
this.setText("");
this.imageLinks = undefined;
this.pendingImages = [];
this.pendingImageLinks = [];
}
/** Treat image/paste markers as indivisible: a stray backspace deletes the whole token
* instead of corrupting `[Paste #1, +30 lines]` into plain text. */
override atomicTokenPattern = PLACEHOLDER_REGEX;
@@ -5,7 +5,7 @@ import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
import { extractTextContent } from "../../commit/utils";
import { settings } from "../../config/settings";
import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
import type { AssistantMessageComponent } from "../../modes/components/assistant-message";
import { detectCacheInvalidation } from "../../modes/components/cache-invalidation-marker";
import {
ReadToolGroupComponent,
@@ -25,6 +25,7 @@ import type { ResolveToolDetails } from "../../tools/resolve";
import { vocalizer } from "../../tts/vocalizer";
import { canonicalizeMessage } from "../../utils/thinking-display";
import { interruptHint } from "../shared";
import { createAssistantMessageComponent } from "../utils/interactive-context-helpers";
import { StreamingRevealController } from "./streaming-reveal";
import { ToolArgsRevealController } from "./tool-args-reveal";
@@ -332,14 +333,7 @@ export class EventController {
this.ctx.ui.requestRender();
} else if (event.message.role === "assistant") {
this.#lastVisibleBlockCount = 0;
this.ctx.streamingComponent = new AssistantMessageComponent(
undefined,
this.ctx.hideThinkingBlock,
() => this.ctx.ui.requestRender(),
this.ctx.viewSession.extensionRunner?.getAssistantThinkingRenderers(),
this.ctx.ui.imageBudget,
this.ctx.proseOnlyThinking,
);
this.ctx.streamingComponent = createAssistantMessageComponent(this.ctx);
this.ctx.streamingMessage = event.message;
this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
this.#streamingReveal.begin(this.ctx.streamingComponent, this.ctx.streamingMessage);
@@ -530,10 +530,7 @@ export class InputController {
// model continues the prior intent rather than second-guessing the interrupt.
if (text === "." || text === "c") {
if (this.ctx.onInputCallback) {
this.ctx.editor.setText("");
this.ctx.pendingImages = [];
this.ctx.pendingImageLinks = [];
this.ctx.editor.imageLinks = undefined;
this.ctx.editor.clearDraft();
this.ctx.onInputCallback({
text: manualContinuePrompt,
cancelled: false,
@@ -546,16 +543,14 @@ export class InputController {
}
const runner = this.ctx.session.extensionRunner;
let inputImages = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
let inputImageLinks = this.ctx.pendingImageLinks.length > 0 ? [...this.ctx.pendingImageLinks] : undefined;
let inputImages = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
let inputImageLinks =
this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined;
if (runner?.hasHandlers("input")) {
const result = await runner.emitInput(text, inputImages, "interactive");
if (result?.handled) {
this.ctx.editor.setText("");
this.ctx.pendingImages = [];
this.ctx.pendingImageLinks = [];
this.ctx.editor.imageLinks = undefined;
this.ctx.editor.clearDraft();
return;
}
if (result?.text !== undefined) {
@@ -603,12 +598,8 @@ export class InputController {
this.ctx.showStatus("This collab link is read-only — prompting is disabled");
return;
}
this.ctx.editor.addToHistory(text);
this.ctx.editor.setText("");
this.ctx.editor.imageLinks = undefined;
const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
this.ctx.pendingImages = [];
this.ctx.pendingImageLinks = [];
this.ctx.editor.clearDraft(text);
// No local render: the prompt comes back from the host as a
// collab-prompt event/entry and renders with the author badge.
this.ctx.collabGuest.sendPrompt(text, images);
@@ -680,8 +671,8 @@ export class InputController {
this.ctx.editor.setText("");
this.ctx.editor.imageLinks = undefined;
const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
this.ctx.pendingImages = [];
this.ctx.pendingImageLinks = [];
this.ctx.editor.pendingImages = [];
this.ctx.editor.pendingImageLinks = [];
// Record the signature so the queued message's eventual delivery
// (a user-role `message_start` event) leaves any draft the user has
// typed since queuing intact. Same protection as #783, applied to
@@ -744,8 +735,8 @@ export class InputController {
// Include any pending images from clipboard paste
this.ctx.editor.imageLinks = undefined;
const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
this.ctx.pendingImages = [];
this.ctx.pendingImageLinks = [];
this.ctx.editor.pendingImages = [];
this.ctx.editor.pendingImageLinks = [];
// Render user message immediately, then let session events catch up.
// Tag the submission as "steer": this is a normal Enter the controller
@@ -771,8 +762,8 @@ export class InputController {
// semantics instead of throwing AgentBusyError.
this.ctx.editor.imageLinks = undefined;
const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
this.ctx.pendingImages = [];
this.ctx.pendingImageLinks = [];
this.ctx.editor.pendingImages = [];
this.ctx.editor.pendingImageLinks = [];
try {
await this.ctx.withLocalSubmission(
text,
@@ -787,9 +778,11 @@ export class InputController {
// extension command).
this.ctx.editor.setText(text);
if (images && images.length > 0) {
this.ctx.pendingImages = [...images];
this.ctx.pendingImageLinks = inputImageLinks ? [...inputImageLinks] : images.map(() => undefined);
this.ctx.editor.imageLinks = this.ctx.pendingImageLinks;
this.ctx.editor.pendingImages = [...images];
this.ctx.editor.pendingImageLinks = inputImageLinks
? [...inputImageLinks]
: images.map(() => undefined);
this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks;
}
this.ctx.showError(error instanceof Error ? error.message : String(error));
}
@@ -816,12 +809,8 @@ export class InputController {
this.ctx.showStatus("Commands run in the main session — press ←← to return first");
return; // editor text not cleared: Editor does not auto-clear on submit
}
const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
this.ctx.editor.addToHistory(text);
this.ctx.editor.setText("");
this.ctx.editor.imageLinks = undefined;
this.ctx.pendingImages = [];
this.ctx.pendingImageLinks = [];
const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
this.ctx.editor.clearDraft(text);
try {
// prompt() handles idle (new turn) and streaming (queues per streamingBehavior).
await this.ctx.withLocalSubmission(text, () => target.prompt(text, { streamingBehavior, images }), {
@@ -989,10 +978,7 @@ export class InputController {
}
const didRetry = await this.ctx.viewSession.retry();
if (didRetry) {
this.ctx.editor.setText("");
this.ctx.pendingImages = [];
this.ctx.pendingImageLinks = [];
this.ctx.editor.imageLinks = undefined;
this.ctx.editor.clearDraft();
} else {
this.ctx.showStatus("Nothing to retry");
}
@@ -1016,7 +1002,7 @@ export class InputController {
// the queued entry is later re-parsed into a skill invocation is a
// separate concern owned by the compaction-resume path.
if (this.ctx.session.isCompacting) {
const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
this.ctx.queueCompactionMessage(text, "followUp", images);
return;
}
@@ -1040,14 +1026,10 @@ export class InputController {
// Forward any pending clipboard-pasted images alongside the queued text;
// otherwise the follow-up would drop the image (mirrors the Enter/steer path).
const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
if (this.ctx.session.isStreaming) {
this.ctx.editor.addToHistory(text);
this.ctx.editor.setText("");
this.ctx.editor.imageLinks = undefined;
this.ctx.pendingImages = [];
this.ctx.pendingImageLinks = [];
this.ctx.editor.clearDraft(text);
await this.ctx.withLocalSubmission(
text,
() => this.ctx.session.prompt(text, { streamingBehavior: "followUp", images }),
@@ -1059,11 +1041,7 @@ export class InputController {
}
// Not streaming — just submit normally
this.ctx.editor.addToHistory(text);
this.ctx.editor.setText("");
this.ctx.editor.imageLinks = undefined;
this.ctx.pendingImages = [];
this.ctx.pendingImageLinks = [];
this.ctx.editor.clearDraft(text);
await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, { images }), {
imageCount: images?.length ?? 0,
});
@@ -1108,7 +1086,7 @@ export class InputController {
let queuedText: string;
if (queuedImages.length > 0) {
const parts: string[] = [];
let imageOffset = this.ctx.pendingImages.length;
let imageOffset = this.ctx.editor.pendingImages.length;
for (const entry of allQueued) {
parts.push(shiftImageMarkers(entry.text, imageOffset));
if (entry.images && entry.images.length > 0) imageOffset += entry.images.length;
@@ -1124,9 +1102,9 @@ export class InputController {
// re-materialized lazily; the restored text already carries the
// renumbered `[Image #N, WxH]` markers).
if (queuedImages.length > 0) {
this.ctx.pendingImages.push(...queuedImages);
this.ctx.pendingImageLinks.push(...queuedImages.map(() => undefined));
this.ctx.editor.imageLinks = this.ctx.pendingImageLinks;
this.ctx.editor.pendingImages.push(...queuedImages);
this.ctx.editor.pendingImageLinks.push(...queuedImages.map(() => undefined));
this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks;
}
this.ctx.updatePendingMessagesDisplay();
if (options?.abort) {
@@ -1148,14 +1126,14 @@ export class InputController {
this.ctx.sessionManager.putBlob.bind(this.ctx.sessionManager),
)
)?.[0];
this.ctx.pendingImages.push({
this.ctx.editor.pendingImages.push({
type: "image",
data: imageData.data,
mimeType: imageData.mimeType,
});
this.ctx.pendingImageLinks.push(imageLink);
this.ctx.editor.imageLinks = this.ctx.pendingImageLinks;
const imageNum = this.ctx.pendingImages.length;
this.ctx.editor.pendingImageLinks.push(imageLink);
this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks;
const imageNum = this.ctx.editor.pendingImages.length;
const dims = await this.#imageDimensions(imageData);
const label = dims ? `[Image #${imageNum}, ${dims.width}x${dims.height}]` : `[Image #${imageNum}]`;
this.ctx.editor.insertText(`${label} `);
@@ -408,8 +408,6 @@ export class InteractiveMode implements InteractiveModeContext {
todoPhases: TodoPhase[] = [];
hideThinkingBlock = false;
proseOnlyThinking = true;
pendingImages: ImageContent[] = [];
pendingImageLinks: (string | undefined)[] = [];
compactionQueuedMessages: CompactionQueuedMessage[] = [];
pendingTools = new Map<string, ToolExecutionHandle>();
pendingBashComponents: BashExecutionComponent[] = [];
@@ -1094,7 +1092,7 @@ export class InteractiveMode implements InteractiveModeContext {
if (this.#goalSuppressNextContinuation) return;
if (this.#pendingSubmittedInput) return;
if (this.editor.getText().trim().length > 0) return;
if ((this.pendingImages?.length ?? 0) > 0) return;
if ((this.editor.pendingImages?.length ?? 0) > 0) return;
const state = this.session.getGoalModeState();
if (!state?.enabled || state.goal.status !== "active") return;
const prompt = this.session.goalRuntime.buildContinuationPrompt();
@@ -1113,7 +1111,7 @@ export class InteractiveMode implements InteractiveModeContext {
if (this.#isAutoSubmitBlocked()) return;
if (this.#pendingSubmittedInput) return;
if (this.editor.getText().trim().length > 0) return;
if ((this.pendingImages?.length ?? 0) > 0) return;
if ((this.editor.pendingImages?.length ?? 0) > 0) return;
const latestState = this.session.getGoalModeState();
if (!latestState?.enabled || latestState.goal.status !== "active") return;
this.#goalContinuationTurnInFlight = true;
@@ -1338,9 +1336,9 @@ export class InteractiveMode implements InteractiveModeContext {
this.#stopLoadingAnimation(true);
}
if (!submission.customType) {
this.pendingImages = submission.images ? [...submission.images] : [];
this.pendingImageLinks = submission.imageLinks ? [...submission.imageLinks] : [];
this.editor.imageLinks = this.pendingImageLinks;
this.editor.pendingImages = submission.images ? [...submission.images] : [];
this.editor.pendingImageLinks = submission.imageLinks ? [...submission.imageLinks] : [];
this.editor.imageLinks = this.editor.pendingImageLinks;
this.rebuildChatFromMessages();
this.editor.setText(submission.text);
}
-2
View File
@@ -160,8 +160,6 @@ export interface InteractiveModeContext {
planModePlanFilePath?: string;
hideThinkingBlock: boolean;
proseOnlyThinking: boolean;
pendingImages: ImageContent[];
pendingImageLinks: (string | undefined)[];
compactionQueuedMessages: CompactionQueuedMessage[];
pendingTools: Map<string, ToolExecutionHandle>;
pendingBashComponents: BashExecutionComponent[];
@@ -0,0 +1,27 @@
/**
* Small helpers over {@link InteractiveModeContext} shared between
* {@link UiHelpers} and the input/event controllers, so the live chat surfaces
* construct components and reset editor state identically.
*/
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { AssistantMessageComponent } from "../components/assistant-message";
import type { InteractiveModeContext } from "../types";
/**
* Construct an {@link AssistantMessageComponent} wired to the live context's
* thinking/image settings. `message` is omitted for the streaming placeholder
* component and supplied when rendering a persisted turn.
*/
export function createAssistantMessageComponent(
ctx: InteractiveModeContext,
message?: AssistantMessage,
): AssistantMessageComponent {
return new AssistantMessageComponent(
message,
ctx.hideThinkingBlock,
() => ctx.ui.requestRender(),
ctx.viewSession.extensionRunner?.getAssistantThinkingRenderers(),
ctx.ui.imageBudget,
ctx.proseOnlyThinking,
);
}
@@ -0,0 +1,157 @@
/**
* Render helpers shared between the live transcript ({@link UiHelpers}) and the
* file/remote-backed {@link ChatTranscriptBuilder}. Both surfaces build the same
* transcript rows from persisted message entries; holding the row construction
* here keeps the two byte-for-byte identical.
*/
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { type Component, Text } from "@oh-my-pi/pi-tui";
import { formatBytes, formatDuration } from "@oh-my-pi/pi-utils";
import { type CustomMessage, type FileMentionMessage, isSilentAbort, resolveAbortLabel } from "../../session/messages";
import { createIrcMessageCard } from "../../tools/irc";
import { canonicalizeMessage } from "../../utils/thinking-display";
import { TranscriptBlock } from "../components/transcript-container";
import { theme } from "../theme/theme";
type CustomOrHookMessage = Extract<AgentMessage, { role: "custom" | "hookMessage" }>;
type AssistantAgentMessage = Extract<AgentMessage, { role: "assistant" }>;
/**
* Render an `async-result` custom message (a completed background bash/task job,
* or a batch of them) as a transcript block of one "Background job completed"
* row per job.
*/
export function buildAsyncResultBlock(message: CustomOrHookMessage): TranscriptBlock {
const details = (
message as CustomMessage<{
jobId?: string;
type?: "bash" | "task";
label?: string;
durationMs?: number;
jobs?: Array<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number }>;
}>
).details;
const jobs =
details?.jobs && details.jobs.length > 0
? details.jobs
: [
{
jobId: details?.jobId,
type: details?.type,
label: details?.label,
durationMs: details?.durationMs,
},
];
const block = new TranscriptBlock();
for (const job of jobs) {
const jobId = job.jobId ?? "unknown";
const typeLabel = job.type ? `[${job.type}]` : "[job]";
const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined;
const line = [
theme.fg("success", `${theme.status.done} Background job completed`),
theme.fg("dim", typeLabel),
theme.fg("accent", jobId),
duration ? theme.fg("dim", `(${duration})`) : undefined,
]
.filter(Boolean)
.join(" ");
block.addChild(new Text(line, 1, 0));
}
return block;
}
/**
* Render a live IRC traffic custom message (`irc:incoming` / `irc:autoreply` /
* `irc:relay`) as a transcript card. `getExpanded` supplies the live
* expanded-state getter for the cached card.
*/
export function buildIrcMessageCard(message: CustomOrHookMessage, getExpanded: () => boolean): Component {
const details = (
message as CustomMessage<{ from?: string; to?: string; message?: string; body?: string; replyTo?: string }>
).details;
const kind =
message.customType === "irc:incoming"
? ("incoming" as const)
: message.customType === "irc:autoreply"
? ("autoreply" as const)
: ("relay" as const);
return createIrcMessageCard(
{
kind,
from: details?.from,
to: details?.to,
body: kind === "incoming" ? details?.message : details?.body,
replyTo: details?.replyTo,
timestamp: message.timestamp,
},
getExpanded,
theme,
);
}
/**
* Render a `fileMention` message's files as a transcript block of "Read <path>"
* rows. `indent` sets the left pad: the live chat renders within an outer gutter
* (0), the transcript viewer renders body rows without one so rows own their pad
* (1).
*/
export function buildFileMentionBlock(files: FileMentionMessage["files"], indent: number): TranscriptBlock {
const block = new TranscriptBlock();
for (const file of files) {
let suffix: string;
if (file.skippedReason === "tooLarge") {
const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size";
suffix = `(skipped: ${size})`;
} else {
suffix = file.image
? "(image)"
: file.lineCount === undefined
? "(unknown lines)"
: `(${file.lineCount} lines)`;
}
const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg(
"accent",
file.path,
)} ${theme.fg("dim", suffix)}`;
block.addChild(new Text(text, indent, 0));
}
return block;
}
/**
* Whether an assistant turn has visible text or thinking content (after
* canonicalization) — i.e. content that closes the current read-tool run.
*/
export function assistantHasVisibleContent(message: AssistantAgentMessage): boolean {
return message.content.some(
content =>
(content.type === "text" && canonicalizeMessage(content.text)) ||
(content.type === "thinking" && canonicalizeMessage(content.thinking)),
);
}
/**
* Normalize raw tool-call arguments to a plain record, collapsing non-object or
* array values to an empty object.
*/
export function normalizeToolArgs(args: unknown): Record<string, unknown> {
return args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {};
}
/**
* Resolve the inline error label, if any, for a turn-ending assistant message.
* Silent aborts yield no label. `retryAttempt` tunes the abort label wording.
*/
export function resolveAssistantErrorMessage(
message: AssistantAgentMessage,
retryAttempt = 0,
): { hasErrorStop: boolean; errorMessage: string | null } {
const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage);
const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error");
const errorMessage = hasErrorStop
? message.stopReason === "aborted"
? resolveAbortLabel(message.errorMessage, retryAttempt)
: message.errorMessage || "Error"
: null;
return { hasErrorStop, errorMessage };
}
@@ -39,16 +39,20 @@ import type { CompactionQueuedMessage, InteractiveModeContext } from "../../mode
import {
BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
type CustomMessage,
isSilentAbort,
LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
resolveAbortLabel,
SKILL_PROMPT_MESSAGE_TYPE,
type SkillPromptDetails,
} from "../../session/messages";
import type { SessionContext } from "../../session/session-context";
import { createIrcMessageCard } from "../../tools/irc";
import { formatBytes, formatDuration } from "../../tools/render-utils";
import { canonicalizeMessage } from "../../utils/thinking-display";
import { createAssistantMessageComponent } from "./interactive-context-helpers";
import {
assistantHasVisibleContent,
buildAsyncResultBlock,
buildFileMentionBlock,
buildIrcMessageCard,
normalizeToolArgs,
resolveAssistantErrorMessage,
} from "./transcript-render-helpers";
type TextBlock = { type: "text"; text: string };
interface RenderInitialMessagesOptions {
@@ -143,47 +147,7 @@ export class UiHelpers {
case "custom": {
if (message.display) {
if (message.customType === "async-result") {
const details = (
message as CustomMessage<{
jobId?: string;
type?: "bash" | "task";
label?: string;
durationMs?: number;
jobs?: Array<{
jobId?: string;
type?: "bash" | "task";
label?: string;
durationMs?: number;
}>;
}>
).details;
const jobs =
details?.jobs && details.jobs.length > 0
? details.jobs
: [
{
jobId: details?.jobId,
type: details?.type,
label: details?.label,
durationMs: details?.durationMs,
},
];
const block = new TranscriptBlock();
for (const job of jobs) {
const jobId = job.jobId ?? "unknown";
const typeLabel = job.type ? `[${job.type}]` : "[job]";
const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined;
const line = [
theme.fg("success", `${theme.status.done} Background job completed`),
theme.fg("dim", typeLabel),
theme.fg("accent", jobId),
duration ? theme.fg("dim", `(${duration})`) : undefined,
]
.filter(Boolean)
.join(" ");
block.addChild(new Text(line, 1, 0));
}
this.ctx.chatContainer.addChild(block);
this.ctx.chatContainer.addChild(buildAsyncResultBlock(message));
break;
}
if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) {
@@ -213,33 +177,7 @@ export class UiHelpers {
message.customType === "irc:autoreply" ||
message.customType === "irc:relay"
) {
const details = (
message as CustomMessage<{
from?: string;
to?: string;
message?: string;
body?: string;
replyTo?: string;
}>
).details;
const kind =
message.customType === "irc:incoming"
? ("incoming" as const)
: message.customType === "irc:autoreply"
? ("autoreply" as const)
: ("relay" as const);
const card = createIrcMessageCard(
{
kind,
from: details?.from,
to: details?.to,
body: kind === "incoming" ? details?.message : details?.body,
replyTo: details?.replyTo,
timestamp: message.timestamp,
},
() => this.ctx.toolOutputExpanded,
theme,
);
const card = buildIrcMessageCard(message, () => this.ctx.toolOutputExpanded);
this.ctx.chatContainer.addChild(card);
return [card];
}
@@ -284,25 +222,7 @@ export class UiHelpers {
}
case "fileMention": {
// Render compact file mention display
const block = new TranscriptBlock();
for (const file of message.files) {
let suffix: string;
if (file.skippedReason === "tooLarge") {
const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size";
suffix = `(skipped: ${size})`;
} else {
suffix = file.image
? "(image)"
: file.lineCount === undefined
? "(unknown lines)"
: `(${file.lineCount} lines)`;
}
const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg(
"accent",
file.path,
)} ${theme.fg("dim", suffix)}`;
block.addChild(new Text(text, 0, 0));
}
const block = buildFileMentionBlock(message.files, 0);
if (block.children.length > 0) this.ctx.chatContainer.addChild(block);
break;
}
@@ -326,14 +246,7 @@ export class UiHelpers {
break;
}
case "assistant": {
const assistantComponent = new AssistantMessageComponent(
message,
this.ctx.hideThinkingBlock,
() => this.ctx.ui.requestRender(),
this.ctx.viewSession.extensionRunner?.getAssistantThinkingRenderers(),
this.ctx.ui.imageBudget,
this.ctx.proseOnlyThinking,
);
const assistantComponent = createAssistantMessageComponent(this.ctx, message);
this.ctx.chatContainer.addChild(assistantComponent);
break;
}
@@ -425,11 +338,7 @@ export class UiHelpers {
this.ctx.lastAssistantUsage = usage;
}
}
const hasVisibleAssistantContent = message.content.some(
content =>
(content.type === "text" && canonicalizeMessage(content.text)) ||
(content.type === "thinking" && canonicalizeMessage(content.thinking)),
);
const hasVisibleAssistantContent = assistantHasVisibleContent(message);
if (hasVisibleAssistantContent) {
// Rebuild reconstructs immutable history; seal (not finalize) so the
// group freezes even if a read's result was never persisted —
@@ -438,14 +347,10 @@ export class UiHelpers {
readGroup?.seal();
readGroup = null;
}
const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage);
const hasErrorStop =
!isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error");
const errorMessage = hasErrorStop
? message.stopReason === "aborted"
? resolveAbortLabel(message.errorMessage, this.ctx.viewSession.retryAttempt)
: message.errorMessage || "Error"
: null;
const { hasErrorStop, errorMessage } = resolveAssistantErrorMessage(
message,
this.ctx.viewSession.retryAttempt,
);
// Render tool call components
for (const content of message.content) {
@@ -474,10 +379,7 @@ export class UiHelpers {
content.id,
);
} else {
const normalizedArgs =
content.arguments && typeof content.arguments === "object" && !Array.isArray(content.arguments)
? (content.arguments as Record<string, unknown>)
: {};
const normalizedArgs = normalizeToolArgs(content.arguments);
readToolCallArgs.set(content.id, normalizedArgs);
if (assistantComponent) {
readToolCallAssistantComponents.set(content.id, assistantComponent);
@@ -647,10 +549,7 @@ export class UiHelpers {
}
clearEditor(): void {
this.ctx.editor.setText("");
this.ctx.pendingImages = [];
this.ctx.pendingImageLinks = [];
this.ctx.editor.imageLinks = undefined;
this.ctx.editor.clearDraft();
this.ctx.ui.requestRender();
}
@@ -719,11 +618,7 @@ export class UiHelpers {
queueCompactionMessage(text: string, mode: "steer" | "followUp", images?: ImageContent[]): void {
const queuedImages = images && images.length > 0 ? images : undefined;
this.ctx.compactionQueuedMessages.push({ text, mode, images: queuedImages } as CompactionQueuedMessage);
this.ctx.editor.addToHistory(text);
this.ctx.editor.setText("");
this.ctx.editor.imageLinks = undefined;
this.ctx.pendingImages = [];
this.ctx.pendingImageLinks = [];
this.ctx.editor.clearDraft(text);
this.ctx.updatePendingMessagesDisplay();
this.ctx.showStatus(
queuedImages ? "Queued message with image for after compaction" : "Queued message for after compaction",
@@ -13,4 +13,3 @@ Matching paths sorted by mtime (newest first), grouped under `# <dir>/` headers
<avoid>
Open-ended searches needing multiple rounds of globbing/searching: you MUST use the Task tool instead.
</avoid>
@@ -62,16 +62,22 @@ function makeCtx(initialQueue: CompactionQueuedMessage[] = []) {
const ctx = {
session,
compactionQueuedMessages: [...initialQueue],
pendingImages: [] as ImageContent[],
pendingImageLinks: [] as (string | undefined)[],
pendingMessagesContainer: { clear: () => {}, addChild: () => {}, removeChild: () => {} },
editor: {
addToHistory: () => {},
clearDraft: (_historyText?: string) => {
editorText = "";
ctx.editor.pendingImages = [];
ctx.editor.pendingImageLinks = [];
ctx.editor.imageLinks = undefined;
},
setText: (text: string) => {
editorText = text;
},
getText: () => editorText,
imageLinks: undefined as (string | undefined)[] | undefined,
pendingImages: [] as ImageContent[],
pendingImageLinks: [] as (string | undefined)[],
},
keybindings: { getDisplayString: () => "Alt+Up" },
fileSlashCommands: new Set<string>(),
@@ -95,8 +101,8 @@ describe("compaction queue image forwarding", () => {
test("queueCompactionMessage stores images and consumes pending-image state", () => {
const image = img("aGVsbG8=");
const { ctx } = makeCtx();
ctx.pendingImages = [image];
ctx.pendingImageLinks = ["clipboard"];
ctx.editor.pendingImages = [image];
ctx.editor.pendingImageLinks = ["clipboard"];
ctx.editor.imageLinks = ["clipboard"];
new UiHelpers(ctx).queueCompactionMessage("look at this screenshot", "steer", [image]);
@@ -105,8 +111,8 @@ describe("compaction queue image forwarding", () => {
{ text: "look at this screenshot", mode: "steer", images: [image] },
]);
// Pending state is consumed so the next message does not resend the image.
expect(ctx.pendingImages).toEqual([]);
expect(ctx.pendingImageLinks).toEqual([]);
expect(ctx.editor.pendingImages).toEqual([]);
expect(ctx.editor.pendingImageLinks).toEqual([]);
expect(ctx.editor.imageLinks).toBeUndefined();
});
@@ -153,7 +159,7 @@ describe("compaction queue Alt+Up restore", () => {
const { ctx } = makeCtx([{ text: "look", mode: "steer", images: [image] }]);
const restored = new InputController(ctx).restoreQueuedMessagesToEditor();
expect(restored).toBe(1);
expect(ctx.pendingImages).toEqual([image]);
expect(ctx.editor.pendingImages).toEqual([image]);
});
test("session and compaction queues restore in pending-bar order", () => {
@@ -192,7 +198,15 @@ describe("restoreQueuedMessagesToEditor image marker alignment", () => {
},
getText: () => editorText,
addToHistory: () => {},
clearDraft: (_historyText?: string) => {
editorText = "";
editor.pendingImages = [];
editor.pendingImageLinks = [];
editor.imageLinks = undefined;
},
imageLinks: undefined as (string | undefined)[] | undefined,
pendingImages: opts.draftImages ? [...opts.draftImages] : ([] as ImageContent[]),
pendingImageLinks: opts.draftImages ? opts.draftImages.map(() => undefined) : ([] as (string | undefined)[]),
};
const session = {
clearQueue: mock(() => ({ steering: opts.queued ?? [], followUp: [] })),
@@ -201,8 +215,6 @@ describe("restoreQueuedMessagesToEditor image marker alignment", () => {
const ctx = {
session,
editor,
pendingImages: opts.draftImages ? [...opts.draftImages] : ([] as ImageContent[]),
pendingImageLinks: opts.draftImages ? opts.draftImages.map(() => undefined) : ([] as (string | undefined)[]),
compactionQueuedMessages: [],
locallySubmittedUserSignatures: new Set<string>(),
updatePendingMessagesDisplay: () => {},
@@ -225,10 +237,10 @@ describe("restoreQueuedMessagesToEditor image marker alignment", () => {
// marker is bumped to #2 because the queued image is appended at slot 1.
expect(restored).toBe(1);
expect(editor.getText()).toBe("[Image #2] queued text\n\n[Image #1] draft text");
expect(ctx.pendingImages).toEqual([draftImg, queuedImg]);
expect(ctx.editor.pendingImages).toEqual([draftImg, queuedImg]);
// Marker → image positional mapping after restore.
expect(ctx.pendingImages[0]).toBe(draftImg); // matches [Image #1]
expect(ctx.pendingImages[1]).toBe(queuedImg); // matches [Image #2]
expect(ctx.editor.pendingImages[0]).toBe(draftImg); // matches [Image #1]
expect(ctx.editor.pendingImages[1]).toBe(queuedImg); // matches [Image #2]
});
test("preserves the WxH metadata tail when renumbering", () => {
@@ -263,7 +275,7 @@ describe("restoreQueuedMessagesToEditor image marker alignment", () => {
// msg1 markers shift by 1 (draft images), msg2 markers shift by 1+1=2.
expect(editor.getText()).toBe("first [Image #2]\n\nsecond [Image #3] and [Image #4]\n\nsee [Image #1]");
expect(ctx.pendingImages).toEqual([draftImg, queued1, queued2a, queued2b]);
expect(ctx.editor.pendingImages).toEqual([draftImg, queued1, queued2a, queued2b]);
});
test("leaves the queued text untouched when the draft has no pending images", () => {
@@ -275,6 +287,6 @@ describe("restoreQueuedMessagesToEditor image marker alignment", () => {
new InputController(ctx).restoreQueuedMessagesToEditor();
expect(editor.getText()).toBe("[Image #1] queued");
expect(ctx.pendingImages).toEqual([queuedImg]);
expect(ctx.editor.pendingImages).toEqual([queuedImg]);
});
});
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "bun:test";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller";
import type { InteractiveModeContext, SubmittedUserInput } from "@oh-my-pi/pi-coding-agent/modes/types";
@@ -32,12 +33,14 @@ type FakeEditor = {
setActionKeys(action: string, keys: string[]): void;
setCustomKeyHandler(key: string, handler: () => void): void;
clearCustomKeyHandlers(): void;
pendingImages: ImageContent[];
pendingImageLinks: (string | undefined)[];
};
function createSubmission(input: {
text: string;
images?: InteractiveModeContext["pendingImages"];
imageLinks?: InteractiveModeContext["pendingImageLinks"];
images?: ImageContent[];
imageLinks?: (string | undefined)[];
}): SubmittedUserInput {
return {
text: input.text,
@@ -99,11 +102,7 @@ function createContext(): {
const updatePendingMessagesDisplay = vi.fn();
const prompt = vi.fn();
const startPendingSubmission = vi.fn(
(input: {
text: string;
images?: InteractiveModeContext["pendingImages"];
imageLinks?: InteractiveModeContext["pendingImageLinks"];
}) => {
(input: { text: string; images?: ImageContent[]; imageLinks?: (string | undefined)[] }) => {
ensureLoadingAnimation();
return createSubmission(input);
},
@@ -119,6 +118,8 @@ function createContext(): {
setActionKeys: vi.fn(),
setCustomKeyHandler: vi.fn(),
clearCustomKeyHandlers: vi.fn(),
pendingImages: [],
pendingImageLinks: [],
};
let ctx!: InteractiveModeContext;
@@ -173,8 +174,6 @@ function createContext(): {
keybindings: {
getKeys: () => [],
} as unknown as InteractiveModeContext["keybindings"],
pendingImages: [],
pendingImageLinks: [],
compactionQueuedMessages: [],
isBashMode: false,
isPythonMode: false,
@@ -15,6 +15,9 @@ interface StubEditor {
getText: () => string;
addToHistory: (text: string) => void;
imageLinks?: unknown;
pendingImages: ImageContent[];
pendingImageLinks: (string | undefined)[];
clearDraft: (text?: string) => void;
}
interface PromptOptionsLike {
streamingBehavior?: "steer" | "followUp";
@@ -31,6 +34,15 @@ function createContext(opts: { isStreaming: boolean; pendingImages: ImageContent
return editorText;
},
addToHistory: vi.fn(),
pendingImages: opts.pendingImages,
pendingImageLinks: opts.pendingImages.map(() => undefined),
clearDraft(text?: string) {
if (text !== undefined) this.addToHistory(text);
this.setText("");
this.imageLinks = undefined;
this.pendingImages = [];
this.pendingImageLinks = [];
},
};
const prompt = vi.fn(async (_text: string, _options?: PromptOptionsLike) => {});
const updatePendingMessagesDisplay = vi.fn();
@@ -48,8 +60,6 @@ function createContext(opts: { isStreaming: boolean; pendingImages: ImageContent
extensionRunner: undefined,
prompt,
},
pendingImages: opts.pendingImages,
pendingImageLinks: opts.pendingImages.map(() => undefined),
loopModeEnabled: false,
compactionQueuedMessages: [],
locallySubmittedUserSignatures: new Set<string>(),
@@ -81,8 +91,8 @@ describe("InputController.handleFollowUp image forwarding", () => {
expect(call[1]?.images).toEqual([image]);
// Pending image state is consumed so the next message does not resend it.
expect(ctx.pendingImages).toEqual([]);
expect(ctx.pendingImageLinks).toEqual([]);
expect(ctx.editor.pendingImages).toEqual([]);
expect(ctx.editor.pendingImageLinks).toEqual([]);
});
it("forwards pending images when not streaming", async () => {
@@ -98,7 +108,7 @@ describe("InputController.handleFollowUp image forwarding", () => {
if (!call) throw new Error("expected session.prompt to be called");
expect(call[1]?.images).toEqual([image]);
expect(call[1]?.streamingBehavior).toBeUndefined();
expect(ctx.pendingImages).toEqual([]);
expect(ctx.editor.pendingImages).toEqual([]);
});
it("omits images when none are pending", async () => {
@@ -32,6 +32,9 @@ type FakeEditor = {
clearCustomKeyHandlers(): void;
pasteText(text: string): void;
imageLinks?: (string | undefined)[];
pendingImages: ImageContent[];
pendingImageLinks: (string | undefined)[];
clearDraft(historyText?: string): void;
};
type InputListenerResult = { consume: boolean } | undefined;
@@ -108,6 +111,15 @@ async function createContext() {
setActionKeys,
setCustomKeyHandler,
clearCustomKeyHandlers,
pendingImages: [],
pendingImageLinks: [],
clearDraft(historyText?: string) {
if (historyText !== undefined) this.addToHistory(historyText);
this.setText("");
this.imageLinks = undefined;
this.pendingImages = [];
this.pendingImageLinks = [];
},
};
focused = editor;
const ctx = {
@@ -132,7 +144,6 @@ async function createContext() {
return keyMap[action] ? [...keyMap[action]] : [];
},
} as InteractiveModeContext["keybindings"],
pendingImages: [],
locallySubmittedUserSignatures: new Set<string>(),
isKnownSlashCommand: () => false,
recordLocalSubmission(this: InteractiveModeContext, text: string, imageCount = 0) {
@@ -304,15 +315,15 @@ describe("InputController keybinding setup", () => {
const controller = new InputController(ctx);
controller.setupKeyHandlers();
ctx.pendingImages = [image];
ctx.pendingImageLinks = ["local://draft.png"];
editor.imageLinks = ctx.pendingImageLinks;
ctx.editor.pendingImages = [image];
ctx.editor.pendingImageLinks = ["local://draft.png"];
editor.imageLinks = ctx.editor.pendingImageLinks;
editor.setText("draft with image");
editor.onRetry?.();
await Promise.resolve();
expect(ctx.pendingImages).toEqual([]);
expect(ctx.pendingImageLinks).toEqual([]);
expect(ctx.editor.pendingImages).toEqual([]);
expect(ctx.editor.pendingImageLinks).toEqual([]);
expect(editor.imageLinks).toBeUndefined();
expect(editor.getText()).toBe("");
});
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
@@ -27,6 +28,8 @@ import { TempDir } from "@oh-my-pi/pi-utils";
type FakeEditor = {
onSubmit?: (text: string) => Promise<void>;
imageLinks?: readonly (string | undefined)[];
pendingImages: ImageContent[];
pendingImageLinks: (string | undefined)[];
setText(text: string): void;
getText(): string;
addToHistory(text: string): void;
@@ -46,6 +49,8 @@ function createContext(sessionOverride?: InteractiveModeContext["session"]) {
const flushPendingBashComponents = vi.fn();
const editor: FakeEditor = {
pendingImages: [] as ImageContent[],
pendingImageLinks: [] as (string | undefined)[],
setText(text: string) {
editorText = text;
},
@@ -77,8 +82,6 @@ function createContext(sessionOverride?: InteractiveModeContext["session"]) {
ui: { requestRender } as unknown as InteractiveModeContext["ui"],
session,
sessionManager: { getSessionName: () => "named-session" } as InteractiveModeContext["sessionManager"],
pendingImages: [] as InteractiveModeContext["pendingImages"],
pendingImageLinks: [] as InteractiveModeContext["pendingImageLinks"],
compactionQueuedMessages: [] as InteractiveModeContext["compactionQueuedMessages"],
fileSlashCommands: new Set<string>(),
locallySubmittedUserSignatures: new Set<string>(),
@@ -184,7 +187,7 @@ describe("InputController orphaned submit", () => {
it("forwards pending images and counts them in the local-submission signature", async () => {
const { ctx, editor, spies } = createContext();
const image = { type: "image", data: "abc", mimeType: "image/png" };
(ctx.pendingImages as unknown[]).push(image);
(ctx.editor.pendingImages as unknown[]).push(image);
const controller = new InputController(ctx);
controller.setupEditorSubmitHandler();
@@ -192,13 +195,13 @@ describe("InputController orphaned submit", () => {
expect(spies.prompt).toHaveBeenCalledWith("look at this", { streamingBehavior: "steer", images: [image] });
expect(ctx.locallySubmittedUserSignatures.has("look at this\u00001")).toBe(true);
expect(ctx.pendingImages.length).toBe(0);
expect(ctx.editor.pendingImages.length).toBe(0);
});
it("restores text and images to the editor when prompt dispatch rejects", async () => {
const { ctx, editor, spies } = createContext();
const image = { type: "image" as const, data: "abc", mimeType: "image/png" };
(ctx.pendingImages as unknown[]).push(image);
(ctx.editor.pendingImages as unknown[]).push(image);
spies.prompt.mockImplementationOnce(async () => {
throw new Error("queue exploded");
});
@@ -210,7 +213,7 @@ describe("InputController orphaned submit", () => {
expect(spies.showError).toHaveBeenCalledWith("queue exploded");
// The message survives the failure: text and images return to the editor.
expect(editor.getText()).toBe("doomed message");
expect(ctx.pendingImages).toEqual([image]);
expect(ctx.editor.pendingImages).toEqual([image]);
// The signature must not leak for a message that never started.
expect(ctx.locallySubmittedUserSignatures.has("doomed message\u00001")).toBe(false);
});
@@ -229,7 +232,7 @@ describe("InputController orphaned submit", () => {
expect(restored).toBe(1);
expect(editor.getText()).toBe("queued with image");
expect(ctx.pendingImages).toEqual([image]);
expect(ctx.pendingImageLinks).toEqual([undefined]);
expect(ctx.editor.pendingImages).toEqual([image]);
expect(ctx.editor.pendingImageLinks).toEqual([undefined]);
});
});
@@ -1,4 +1,5 @@
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 type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
@@ -11,6 +12,8 @@ type FakeEditor = {
setActionKeys(action: string, keys: string[]): void;
setCustomKeyHandler(key: string, handler: () => void): void;
clearCustomKeyHandlers(): void;
pendingImages: ImageContent[];
pendingImageLinks: (string | undefined)[];
};
function createContext() {
@@ -33,6 +36,8 @@ function createContext() {
setActionKeys: vi.fn(),
setCustomKeyHandler: vi.fn(),
clearCustomKeyHandlers: vi.fn(),
pendingImages: [] as ImageContent[],
pendingImageLinks: [] as (string | undefined)[],
};
const ctx = {
@@ -49,8 +54,6 @@ function createContext() {
getQueuedMessages: () => ({ steering: [], followUp: [] }),
} as unknown as InteractiveModeContext["session"],
sessionManager: { getSessionName: () => "named-session" } as unknown as InteractiveModeContext["sessionManager"],
pendingImages: [] as InteractiveModeContext["pendingImages"],
pendingImageLinks: [] as InteractiveModeContext["pendingImageLinks"],
compactionQueuedMessages: [] as InteractiveModeContext["compactionQueuedMessages"],
locallySubmittedUserSignatures: new Set<string>(),
onInputCallback,
@@ -8,6 +8,7 @@
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
@@ -28,6 +29,8 @@ type StubEditor = {
getText: () => string;
addToHistory: Mock<(...args: unknown[]) => unknown>;
onSubmit?: (text: string) => Promise<void>;
pendingImages: ImageContent[];
pendingImageLinks: (string | undefined)[];
};
type PromptCustomMessage = Mock<
@@ -53,6 +56,8 @@ function createStubInputControllerContext(opts: { skillCommands: Map<string, str
return editorText;
},
addToHistory: vi.fn(),
pendingImages: [] as ImageContent[],
pendingImageLinks: [] as (string | undefined)[],
};
const promptCustomMessage: PromptCustomMessage = vi.fn(async () => {});
const prompt = vi.fn(async (_text: string, _options?: unknown) => {});
@@ -83,8 +88,6 @@ function createStubInputControllerContext(opts: { skillCommands: Map<string, str
updatePendingMessagesDisplay,
isBashMode: false,
isPythonMode: false,
pendingImages: [],
pendingImageLinks: [],
loopModeEnabled: false,
compactionQueuedMessages: [],
locallySubmittedUserSignatures: new Set<string>(),
@@ -448,6 +451,8 @@ function createStubInteractiveModeContextForUiHelpers(session: AgentSession) {
return editorText;
},
addToHistory: vi.fn(),
pendingImages: [] as ImageContent[],
pendingImageLinks: [] as (string | undefined)[],
};
const pendingMessagesContainer = new Container();
const requestRender = vi.fn();
@@ -17,6 +17,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as os from "node:os";
import * as path from "node:path";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
@@ -41,14 +42,18 @@ function createContext() {
const requestRender = vi.fn();
const showStatus = vi.fn();
const ctx = {
editor: { pasteText, insertText, imageLinks: undefined } as unknown as InteractiveModeContext["editor"],
editor: {
pasteText,
insertText,
imageLinks: undefined,
pendingImages: [] as ImageContent[],
pendingImageLinks: [] as (string | undefined)[],
} as unknown as InteractiveModeContext["editor"],
ui: { requestRender, getFocused: () => null } as unknown as InteractiveModeContext["ui"],
sessionManager: {
getCwd: () => process.cwd(),
putBlob: async () => ({ hash: "h", path: "/tmp/h.png", displayPath: "/tmp/h.png" }),
} as unknown as InteractiveModeContext["sessionManager"],
pendingImages: [] as InteractiveModeContext["pendingImages"],
pendingImageLinks: [] as InteractiveModeContext["pendingImageLinks"],
showStatus,
} as unknown as InteractiveModeContext;
return { ctx, spies: { pasteText, insertText, requestRender, showStatus } };
@@ -150,8 +155,8 @@ describe("InputController.handleImagePathPaste (issue #2375)", () => {
expect(spies.pasteText).not.toHaveBeenCalled();
expect(spies.showStatus).not.toHaveBeenCalled();
expect(ctx.pendingImages.length).toBe(1);
expect(ctx.pendingImages[0]?.mimeType).toBe("image/png");
expect(ctx.editor.pendingImages.length).toBe(1);
expect(ctx.editor.pendingImages[0]?.mimeType).toBe("image/png");
});
it("locally: attaches the clipboard image when the pasted path resolves to a non-image file", async () => {
@@ -174,7 +179,7 @@ describe("InputController.handleImagePathPaste (issue #2375)", () => {
}
expect(spies.pasteText).not.toHaveBeenCalled();
expect(ctx.pendingImages.length).toBe(1);
expect(ctx.pendingImages[0]?.mimeType).toBe("image/png");
expect(ctx.editor.pendingImages.length).toBe(1);
expect(ctx.editor.pendingImages[0]?.mimeType).toBe("image/png");
});
});
@@ -1,4 +1,5 @@
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 type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import { USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-agent/session/messages";
@@ -19,6 +20,8 @@ function createContext() {
return editorText;
},
addToHistory: vi.fn(),
pendingImages: [] as ImageContent[],
pendingImageLinks: [] as (string | undefined)[],
},
ui: { requestRender },
session: {
@@ -34,8 +37,6 @@ function createContext() {
get viewSession() {
return (this as typeof ctx).session;
},
pendingImages: [],
pendingImageLinks: [],
compactionQueuedMessages: [],
locallySubmittedUserSignatures: new Set<string>(),
isBashMode: false,