From bbd8392ef9886a02be5dc7540920fc2e5e63e267 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 17 Mar 2026 22:25:08 +0100 Subject: [PATCH] fix(coding-agent): merge piped stdin into initial prompt closes #2315 --- packages/coding-agent/README.md | 9 ++++ .../coding-agent/src/cli/initial-message.ts | 43 +++++++++++++++++ packages/coding-agent/src/main.ts | 34 +++++++------- .../coding-agent/test/initial-message.test.ts | 47 +++++++++++++++++++ 4 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 packages/coding-agent/src/cli/initial-message.ts create mode 100644 packages/coding-agent/test/initial-message.test.ts diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 1e30cef4..f4804e99 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -451,6 +451,12 @@ pi config # Enable/disable package resources | `--mode rpc` | RPC mode for process integration (see [docs/rpc.md](docs/rpc.md)) | | `--export [out]` | Export session to HTML | +In print mode, pi also reads piped stdin and merges it into the initial prompt: + +```bash +cat README.md | pi -p "Summarize this text" +``` + ### Model Options | Option | Description | @@ -525,6 +531,9 @@ pi "List all .ts files in src/" # Non-interactive pi -p "Summarize this codebase" +# Non-interactive with piped stdin +cat README.md | pi -p "Summarize this text" + # Different model pi --provider openai --model gpt-4o "Help me refactor" diff --git a/packages/coding-agent/src/cli/initial-message.ts b/packages/coding-agent/src/cli/initial-message.ts new file mode 100644 index 00000000..5745985c --- /dev/null +++ b/packages/coding-agent/src/cli/initial-message.ts @@ -0,0 +1,43 @@ +import type { ImageContent } from "@mariozechner/pi-ai"; +import type { Args } from "./args.js"; + +export interface InitialMessageInput { + parsed: Args; + fileText?: string; + fileImages?: ImageContent[]; + stdinContent?: string; +} + +export interface InitialMessageResult { + initialMessage?: string; + initialImages?: ImageContent[]; +} + +/** + * Combine stdin content, @file text, and the first CLI message into a single + * initial prompt for non-interactive mode. + */ +export function buildInitialMessage({ + parsed, + fileText, + fileImages, + stdinContent, +}: InitialMessageInput): InitialMessageResult { + const parts: string[] = []; + if (stdinContent !== undefined) { + parts.push(stdinContent); + } + if (fileText) { + parts.push(fileText); + } + + if (parsed.messages.length > 0) { + parts.push(parsed.messages[0]); + parsed.messages.shift(); + } + + return { + initialMessage: parts.length > 0 ? parts.join("") : undefined, + initialImages: fileImages && fileImages.length > 0 ? fileImages : undefined, + }; +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 137e38f4..a1666040 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -11,6 +11,7 @@ import { createInterface } from "readline"; import { type Args, parseArgs, printHelp } from "./cli/args.js"; import { selectConfig } from "./cli/config-selector.js"; import { processFileArguments } from "./cli/file-processor.js"; +import { buildInitialMessage } from "./cli/initial-message.js"; import { listModels } from "./cli/list-models.js"; import { selectSession } from "./cli/session-picker.js"; import { APP_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js"; @@ -311,28 +312,22 @@ async function handlePackageCommand(args: string[]): Promise { async function prepareInitialMessage( parsed: Args, autoResizeImages: boolean, + stdinContent?: string, ): Promise<{ initialMessage?: string; initialImages?: ImageContent[]; }> { if (parsed.fileArgs.length === 0) { - return {}; + return buildInitialMessage({ parsed, stdinContent }); } const { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages }); - - let initialMessage: string; - if (parsed.messages.length > 0) { - initialMessage = text + parsed.messages[0]; - parsed.messages.shift(); - } else { - initialMessage = text; - } - - return { - initialMessage, - initialImages: images.length > 0 ? images : undefined, - }; + return buildInitialMessage({ + parsed, + fileText: text, + fileImages: images, + stdinContent, + }); } /** Result from resolving a session argument */ @@ -675,13 +670,12 @@ export async function main(args: string[]) { } // Read piped stdin content (if any) - skip for RPC mode which uses stdin for JSON-RPC + let stdinContent: string | undefined; if (parsed.mode !== "rpc") { - const stdinContent = await readPipedStdin(); + stdinContent = await readPipedStdin(); if (stdinContent !== undefined) { // Force print mode since interactive mode requires a TTY for keyboard input parsed.print = true; - // Prepend stdin content to messages - parsed.messages.unshift(stdinContent); } } @@ -704,7 +698,11 @@ export async function main(args: string[]) { process.exit(1); } - const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize()); + const { initialMessage, initialImages } = await prepareInitialMessage( + parsed, + settingsManager.getImageAutoResize(), + stdinContent, + ); const isInteractive = !parsed.print && parsed.mode === undefined; const mode = parsed.mode || "text"; initTheme(settingsManager.getTheme(), isInteractive); diff --git a/packages/coding-agent/test/initial-message.test.ts b/packages/coding-agent/test/initial-message.test.ts new file mode 100644 index 00000000..d8123bf4 --- /dev/null +++ b/packages/coding-agent/test/initial-message.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "vitest"; +import type { Args } from "../src/cli/args.js"; +import { buildInitialMessage } from "../src/cli/initial-message.js"; + +function createArgs(messages: string[] = []): Args { + return { + messages: [...messages], + fileArgs: [], + unknownFlags: new Map(), + }; +} + +describe("buildInitialMessage", () => { + test("merges piped stdin with the first CLI message into one prompt", () => { + const parsed = createArgs(["Summarize the text given"]); + const result = buildInitialMessage({ + parsed, + stdinContent: "README contents\n", + }); + + expect(result.initialMessage).toBe("README contents\nSummarize the text given"); + expect(parsed.messages).toEqual([]); + }); + + test("uses stdin as the initial prompt when no CLI message is present", () => { + const parsed = createArgs(); + const result = buildInitialMessage({ + parsed, + stdinContent: "README contents", + }); + + expect(result.initialMessage).toBe("README contents"); + expect(parsed.messages).toEqual([]); + }); + + test("combines stdin, file text, and first CLI message in one prompt", () => { + const parsed = createArgs(["Explain it", "Second message"]); + const result = buildInitialMessage({ + parsed, + stdinContent: "stdin\n", + fileText: "file\n", + }); + + expect(result.initialMessage).toBe("stdin\nfile\nExplain it"); + expect(parsed.messages).toEqual(["Second message"]); + }); +});