Skip to content

feat(cli): allow -i/--prompt-interactive with piped stdin#23414

Open
GoBeromsu wants to merge 2 commits intogoogle-gemini:mainfrom
GoBeromsu:feat/headless-interactive
Open

feat(cli): allow -i/--prompt-interactive with piped stdin#23414
GoBeromsu wants to merge 2 commits intogoogle-gemini:mainfrom
GoBeromsu:feat/headless-interactive

Conversation

@GoBeromsu
Copy link

Summary

Extends -i/--prompt-interactive to work when stdin.isTTY is false, enabling multi-turn interactive sessions over pipes for programmatic consumers.

Problem: When the CLI is launched programmatically (e.g., from Node.js, Java, or a backend service), stdin is not a TTY. Using -i with piped stdin immediately exited with FATAL_INPUT_ERROR because Ink TUI requires TTY. This forced programmatic consumers to either use single-shot -p with no session persistence, or adopt the full ACP protocol.

Solution: When -i is used with piped stdin, skip Ink TUI and fall through to a chunk-based stdin reader that processes each line as a separate prompt. Session state persists via shared GeminiClient instance.

Changes

  1. readStdinLines.ts (new utility): Reads piped stdin line-by-line with safety limits matching readStdin():

    • Per-line: 8MB cap enforced during chunk buffering (not post-read like readline.createInterface)
    • Total session: 8MB cumulative cap to bound API calls and data volume
  2. gemini.tsx:

    • Remove -i + non-TTY error guard
    • Gate startInteractiveUI() on isInteractive() && isTTY
    • Skip readStdin() drain when -i is active (stdin consumed by readStdinLines instead)
    • Guard pre-sandbox readStdin() for -i to prevent early stdin consumption
    • Unified prompt loop with prompts() async generator

Behavior matrix

Scenario Before After
-i + TTY Ink TUI Ink TUI (unchanged)
-i + piped stdin FATAL_INPUT_ERROR Multi-turn with session persistence
-p + piped stdin Single prompt, exit Single prompt, exit (unchanged)
Piped stdin, no flag Drain all as one prompt Drain all as one prompt (unchanged)
--acp ACP client ACP client (unchanged)

Design decisions

  • No new flag: -i already means "execute prompt and continue interactive." Extending it to non-TTY is the natural reading of Issue Allow interactive mode to stay alive even when process.stdin.isTTY is false #13924.
  • readStdinLines vs readline.createInterface: readline buffers entire lines unbounded until \n arrives, risking OOM. Our chunk-based reader enforces the 8MB cap during buffering, matching readStdin()'s defense-in-depth pattern.
  • 8MB cumulative cap: Bounds total API calls and data volume per session, consistent with readStdin()'s safety limit.

Known limitations

  • Sandbox mode: Pre-sandbox path injects stdin as --prompt args. Multi-turn not supported in sandbox (follow-up).
  • No response-end signal in text mode: Callers detect response completion by silence or use --output-format stream-json for explicit RESULT events.

Test plan

  • Build passes
  • Existing tests pass (35/35)
  • Updated test: -i + non-TTY no longer exits with FATAL_INPUT_ERROR
  • Manual: printf "what is 1+1?\n" | gemini -i "hello" — two responses
  • Manual: Node.js child_process spawn — session maintained
  • Verify TTY + -i still launches Ink TUI
  • Verify -p unchanged
  • Verify --acp unchanged

Closes #13924

Reads piped stdin line-by-line as an async generator with safety
limits matching readStdin():
- Per-line: 8MB cap (truncates lines exceeding this)
- Total session: 8MB cumulative (stops reading when exceeded)

Reads raw chunks instead of using readline.createInterface to
enforce size caps during buffering and prevent OOM from input
without newline delimiters.
When stdin is not a TTY, -i now falls through to a chunk-based stdin
reader instead of exiting with FATAL_INPUT_ERROR. This enables
multi-turn interactive sessions over pipes:

- TTY + -i: Ink TUI (unchanged)
- Pipe + -i: readStdinLines() with session persistence
- Pipe without -i: readStdin drain as single prompt (unchanged)
- ACP mode: unaffected

Session state persists via shared GeminiClient instance. The
pre-sandbox readStdin() is also guarded for -i to avoid draining
stdin before the interactive path.

Closes google-gemini#13924
@GoBeromsu GoBeromsu requested a review from a team as a code owner March 22, 2026 01:14
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the gemini CLI's flexibility by allowing the -i/--prompt-interactive flag to function with piped stdin. Previously, this combination would result in an error due to TTY requirements. The changes introduce a robust line-by-line stdin reader with built-in safety limits, enabling programmatic consumers to engage in multi-turn interactive sessions with session persistence, thereby broadening the CLI's integration capabilities.

Highlights

  • Interactive CLI with piped stdin: The -i/--prompt-interactive flag now supports piped stdin, enabling multi-turn interactive sessions for programmatic consumers without requiring a TTY.
  • New stdin line reader: Introduced readStdinLines.ts to safely read piped stdin line-by-line, enforcing per-line and cumulative size limits (8MB each) to prevent out-of-memory issues.
  • gemini.tsx logic updates: The gemini.tsx file was updated to remove the previous FATAL_INPUT_ERROR for -i with non-TTY, gate the Ink TUI to TTY environments, and integrate the new readStdinLines utility via a unified prompts async generator.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request effectively extends the -i/--prompt-interactive flag to support piped stdin, which is a great enhancement for programmatic use. The introduction of the prompts async generator in gemini.tsx is a clean way to handle different input modes. However, I've identified a significant issue in the new readStdinLines.ts utility concerning the handling of byte limits and multi-byte characters, which could lead to incorrect behavior and data corruption. Please see the detailed comment.

Comment on lines +20 to +65
export async function* readStdinLines(
stream: NodeJS.ReadableStream = process.stdin,
): AsyncGenerator<string> {
const MAX_LINE_SIZE = 8 * 1024 * 1024; // 8MB per line
const MAX_TOTAL_SIZE = 8 * 1024 * 1024; // 8MB cumulative
let buffer = '';
let totalSize = 0;
stream.setEncoding('utf8');
for await (const chunk of stream) {
buffer += chunk;
let newlineIdx: number;
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIdx).trim();
buffer = buffer.slice(newlineIdx + 1);
if (!line) continue;
totalSize += line.length;
if (totalSize > MAX_TOTAL_SIZE) {
debugLogger.warn(
`Total piped input exceeds ${MAX_TOTAL_SIZE} bytes, stopping.`,
);
return;
}
yield line.length > MAX_LINE_SIZE ? line.slice(0, MAX_LINE_SIZE) : line;
}
// Flush buffer if it exceeds per-line limit without a newline
if (buffer.length > MAX_LINE_SIZE) {
debugLogger.warn(
`Stdin line exceeds ${MAX_LINE_SIZE} bytes, truncating.`,
);
const line = buffer.slice(0, MAX_LINE_SIZE).trim();
buffer = '';
if (line) {
totalSize += line.length;
if (totalSize > MAX_TOTAL_SIZE) return;
yield line;
}
}
}
// Flush remaining buffer after EOF
const remaining = buffer.trim();
if (remaining && totalSize + remaining.length <= MAX_TOTAL_SIZE) {
yield remaining.length > MAX_LINE_SIZE
? remaining.slice(0, MAX_LINE_SIZE)
: remaining;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This function has two related issues regarding the handling of byte limits and multi-byte Unicode characters:

  1. Incorrect Byte Counting: The variables MAX_LINE_SIZE and MAX_TOTAL_SIZE are defined in bytes (8MB), but their usage is based on string.length, which counts UTF-16 code units, not bytes. For multi-byte characters (like many emojis or non-latin scripts), string.length will not equal the number of bytes, leading to incorrect enforcement of the size limits. You should use Buffer.byteLength(line, 'utf8') to get the actual byte size of the string.

  2. Unsafe String Truncation: The use of string.slice() to truncate lines can corrupt multi-byte characters by splitting them in the middle, which can lead to malformed data. The repository's general rules advise against this. A safer, though not perfect, approach for truncation is to work with Buffers: Buffer.from(line).slice(0, MAX_LINE_SIZE).toString('utf8').

Given these issues, I recommend refactoring this function to correctly handle byte lengths and perform safer truncation.

References
  1. When truncating strings that may contain multi-byte Unicode characters (e.g., emojis), use methods that operate on grapheme clusters (like Intl.Segmenter or Array.from()) instead of UTF-16 code units (string.length, string.slice()) to prevent character splitting.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This follows the same pattern as the existing readStdin() (packages/cli/src/utils/readStdin.ts), which also uses setEncoding('utf8') + string.length for size tracking and string.slice() for truncation (lines 14, 33-35). The byte-counting concern is valid in theory, but changing it here would diverge from the established pattern — fixing it would be better suited as a separate PR that addresses both readStdin() and readStdinLines() together.

@gemini-cli gemini-cli bot added priority/p2 Important but can be addressed in a future release. area/non-interactive Issues related to GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation help wanted We will accept PRs from all issues marked as "help wanted". Thanks for your support! labels Mar 22, 2026
@sairohithpasupuleti
Copy link

sairohithpasupuleti commented Mar 22, 2026

Hello @GoBeromsu,

Nice improvement 🙂 Supporting -i with non-TTY makes multi-turn usage through pipes much more useful for programmatic workflows.

One small observation: the limits in readStdinLines() are currently based on string.length, which measures UTF-16 characters rather than UTF-8 bytes. Because of this, the actual 8MB limit can vary slightly when the input includes emojis or non-Latin text.

Not blocking for this PR since readStdin() follows the same approach, but it may be helpful to update both utilities together later for more accurate byte-based limits:

Buffer.byteLength(line, 'utf8')

Overall, the unified prompts() generator keeps the input flow clean and consistent across different modes.

@GoBeromsu
Copy link
Author

Hi @sairohithpasupuleti Thanks. I agree — I’d like to fix the points you mentioned. While looking into it, I also found similar patterns in other parts of the codebase.

The string.length vs UTF-8 byte limit issue makes sense. Since readStdin() currently follows the same approach, I think it would be better to update both utilities together and switch to Buffer.byteLength(..., 'utf8') for consistency and more accurate limits.

@sairohithpasupuleti
Copy link

Hello @GoBeromsu,
Perfect, updating both utilities together will keep behavior consistent and make the limits more accurate.

bkrsna added a commit to bkrsna/gemini-cli that referenced this pull request Mar 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/non-interactive Issues related to GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation help wanted We will accept PRs from all issues marked as "help wanted". Thanks for your support! priority/p2 Important but can be addressed in a future release.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow interactive mode to stay alive even when process.stdin.isTTY is false

2 participants