Skip to content

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

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

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

Conversation

@GoBeromsu
Copy link
Copy Markdown

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

@GoBeromsu GoBeromsu requested a review from a team as a code owner March 22, 2026 01:14
@gemini-code-assist
Copy link
Copy Markdown
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
Copy Markdown
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.

@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
Copy Markdown

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
Copy Markdown
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
Copy Markdown

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
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
Replace string.length (UTF-16 code units) with Buffer.byteLength
for accurate UTF-8 byte tracking in size limits. Add truncateUtf8Bytes
helper that walks back past continuation bytes to find the last
complete character boundary, preventing multi-byte character corruption.

Also fixes totalSize to consistently track post-truncation bytes
across all code paths (newline and flush), resolving an inconsistency
where a 9MB line with a newline would be dropped while the same
content without a newline would be truncated and yielded.

Add comprehensive unit tests covering multi-byte truncation,
cumulative limits, flush path, and CJK/emoji edge cases.

Constraint: readStdin.ts has the same string.length pattern but is
  not modified here to keep scope narrow — tracked in google-gemini#23537
Rejected: Incremental bufferBytes counter | adds complexity for
  a path bounded by 8MB cap, not a practical perf concern
Confidence: high
Scope-risk: narrow
@GoBeromsu
Copy link
Copy Markdown
Author

/gemini review

Copy link
Copy Markdown
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 extends the -i/--prompt-interactive flag to support multi-turn sessions over piped stdin, which was previously not allowed. The changes involve removing the guard that prevented this, and introducing a new readStdinLines utility to process stdin line-by-line in this mode. The overall implementation is well-structured, but I found a critical issue in the new readStdinLines utility that can lead to data loss when processing the final chunk of data from stdin. My review includes a suggested fix for this issue, with an added note on handling Unicode characters during truncation.

Note: Security Review did not run due to the size of the PR.

Comment on lines +87 to +95
const remaining = buffer.trim();
if (remaining) {
const remainingBytes = Buffer.byteLength(remaining, 'utf8');
if (totalSize + remainingBytes <= MAX_TOTAL_SIZE) {
yield remainingBytes > MAX_LINE_SIZE
? truncateUtf8Bytes(remaining, MAX_LINE_SIZE)
: remaining;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

The logic for flushing the remaining buffer at the end of the stream is incorrect and can lead to data loss. The check totalSize + remainingBytes <= MAX_TOTAL_SIZE uses the size of the untruncated remaining buffer. If this buffer is larger than the remaining capacity but could be truncated to fit, it is incorrectly dropped entirely.

For example, if totalSize is 0 and remaining is 9MB, the check 0 + 9MB <= 8MB fails, and nothing is yielded. The expected behavior is to yield a truncated 8MB line.

The logic should first determine the string to be yielded (after potential truncation) and then check if its size fits within the MAX_TOTAL_SIZE limit. When implementing truncateUtf8Bytes, ensure it handles multi-byte Unicode characters (like emojis) correctly by operating on grapheme clusters to prevent character splitting.

  const remaining = buffer.trim();
  if (remaining) {
    const lineToYield =
      Buffer.byteLength(remaining, 'utf8') > MAX_LINE_SIZE
        ? truncateUtf8Bytes(remaining, MAX_LINE_SIZE)
        : remaining;
    const yieldBytes = Buffer.byteLength(lineToYield, 'utf8');
    if (totalSize + yieldBytes <= MAX_TOTAL_SIZE) {
      yield lineToYield;
    }
  }
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.

The EOF flush path checked totalSize against the untruncated remaining
buffer size. A 9MB buffer with totalSize=0 would fail the 8MB check
and yield nothing, even though truncating to 8MB would fit.

Now truncates first, then checks if the truncated result fits within
the cumulative limit — consistent with the newline and buffer-flush
paths.

Constraint: Must match truncate-first pattern used in other paths
Confidence: high
Scope-risk: narrow
@GoBeromsu
Copy link
Copy Markdown
Author

/gemini review

Copy link
Copy Markdown
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 extends the -i/--prompt-interactive flag to support non-TTY environments, enabling multi-turn interactive sessions over piped stdin. This is achieved by introducing a new readStdinLines utility for safe, line-by-line input processing with size limits, and refactoring the main application logic in gemini.tsx to use an async generator that handles different input modes. The changes appear to be well-implemented and correctly address the described problem, with new tests for the added utility.

Note: Security Review is unavailable for this PR.

// Unified prompt loop: yields once for single-shot, multiple times for
// multi-turn pipe sessions. RESULT events signal each response end.
let promptCount = 0;
for await (const prompt of prompts(input, config.isInteractive())) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think, the new piped stdin path has no error handling for broken pipes

Since this PR specifically targets long-running programmatic sessions, the pipe can break at any point - e.g., the parent process crashes, or the network drops in a remote session.

The existing readStdin.ts already handles this with an explicit onError callback (lines 53-56) and a safety net for late errors (lines 70-72). But the new readStdinLines.ts uses a bare for await...of at line 49 with no error handling:

// readStdinLines.ts, line 49 — throws if the stream emits an error
for await (const chunk of stream) {

The consumer in gemini.tsx also has no try-catch around the loop at line 659:

// gemini.tsx, line 659 — no try-catch
for await (const prompt of prompts(input, config.isInteractive())) {

So when the pipe breaks, the error flies past the cleanup code at lines 690-692 (runExitCleanup() + process.exit), which means telemetry doesn't flush, SessionEnd hooks don't fire, and temp files aren't cleaned up.

A lightweight fix would be wrapping the loop in gemini.tsx:

try {
  for await (const prompt of prompts(input, config.isInteractive())) {
    promptCount++;
    // ... existing loop body ...
  }
} catch (err) {
  debugLogger.error('Piped stdin stream error:', err);
}
// Cleanup now runs normally regardless of pipe errors
if (promptCount === 0) { ... }
await runExitCleanup();
process.exit(ExitCodes.SUCCESS);

This way, a broken pipe logs the error and falls through to the existing cleanup path - same graceful behavior that readStdin.ts already provides.

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

3 participants