Skip to content

fix(core): throttle shell text output and bound live UI buffer#26955

Open
emersonbusson wants to merge 4 commits into
google-gemini:mainfrom
emersonbusson:fix-shell-output-jank-25459
Open

fix(core): throttle shell text output and bound live UI buffer#26955
emersonbusson wants to merge 4 commits into
google-gemini:mainfrom
emersonbusson:fix-shell-output-jank-25459

Conversation

@emersonbusson
Copy link
Copy Markdown

Summary

Throttles shell tool text data events to OUTPUT_UPDATE_INTERVAL_MS (1s), matching
the existing binary_progress cadence, and caps the live UI buffer to
LIVE_OUTPUT_MAX_BUFFER_CHARS (100k chars). Without throttling, every chunk
triggered a full React re-render of the shell output panel; commands emitting
thousands of lines (build warnings, manifest generation, verbose test runs)
pinned the UI until the command exited.

Details

This PR builds on the feedback @jacob314 left on the previous attempt (#25461)
and resolves the three concerns raised there:

  1. No initial 1s delaylastUpdateTime starts at 0 and the first text
    chunk renders immediately. Subsequent chunks are throttled.
  2. Bounded live buffer — the new appendToLiveOutputBuffer keeps only the
    last LIVE_OUTPUT_MAX_BUFFER_CHARS characters for the live preview, removing
    the unbounded-string growth risk. The complete output is unaffected: it is
    still rendered from the full result returned by shellExecutionService after
    the command exits.
  3. PTY snapshots not throttledAnsiOutput (PTY mode) chunks bypass the
    throttle entirely, preserving responsiveness for progress bars and
    curses-style UIs.

Final-state guarantee: flushOutput() runs on the exit event and again after
resultPromise resolves (non-background path), so the last frame is always
flushed without delay.

Related Issues

Fixes #25459

Supersedes #22843 (closed under the help-wanted policy) and #25461 (closed for
inactivity after change requests).

How to Validate

  1. Build the bundle: npm run bundle
  2. Run a high-volume shell command from inside the CLI:
    for i in $(seq 1 20000); do echo "line $i"; done
  3. Verify the UI stays responsive while the command runs (no freeze, input
    stays interactive) and the final output is complete and correct.
  4. Run a PTY-style command (e.g. anything that emits ANSI progress) and verify
    updates still appear at full rate (no throttle on PTY).
  5. Run the regression tests:
    npm test -w @google/gemini-cli-core -- src/tools/shell.test.ts

Pre-Merge Checklist

  • Updated relevant documentation and README (n/a — internal throttle, no
    user-facing API change)
  • Added/updated tests (4 new regression tests in shell.test.ts)
  • Noted breaking changes (none)
  • Validated on required platforms/methods:
    • MacOS
    • Windows
    • Linux
      • npm run
      • npx
      • Docker

@emersonbusson emersonbusson requested a review from a team as a code owner May 12, 2026 21:50
@google-cla
Copy link
Copy Markdown

google-cla Bot commented May 12, 2026

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@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 optimizes the shell tool's UI responsiveness by throttling text output and bounding the live buffer size. By preventing excessive React re-renders during high-volume command execution, the changes ensure the interface remains interactive. The implementation guarantees that the initial output is shown immediately, subsequent updates are throttled, and the final state is correctly flushed upon command completion, all while preserving full performance for PTY-based terminal outputs.

Highlights

  • Output Throttling: Implemented a 1-second throttle for shell tool text output to prevent UI performance degradation during high-volume command execution.
  • Buffer Management: Introduced a 100k character limit for the live UI buffer to prevent unbounded memory growth while maintaining full output availability upon command completion.
  • PTY Preservation: Ensured that PTY-based ANSI output bypasses the throttle to maintain responsiveness for interactive terminal interfaces.
  • Testing: Added four new regression tests in shell.test.ts to verify immediate initial output, trailing output flushing, buffer capping, and PTY bypass behavior.
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 the 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 counterproductive. 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.

@emersonbusson
Copy link
Copy Markdown
Author

@googlebot I signed it!

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 introduces output throttling and a bounded buffer for live shell output in the ShellTool to improve performance. It adds logic to manage a rolling output buffer and throttles UI updates to a fixed interval. Review feedback suggests optimizing data transfer by emitting only incremental deltas instead of the full buffer and implementing a timer-based trailing flush to handle silent but active processes.

Comment on lines +506 to +535
let lastUpdateTime = 0;
let hasFlushedOutput = false;
let hasPendingOutput = false;
let isBinaryStream = false;

const appendToLiveOutputBuffer = (chunk: string) => {
const currentOutput =
typeof cumulativeOutput === 'string' ? cumulativeOutput : '';
if (chunk.length >= LIVE_OUTPUT_MAX_BUFFER_CHARS) {
cumulativeOutput = chunk.slice(-LIVE_OUTPUT_MAX_BUFFER_CHARS);
return;
}

const nextOutput = currentOutput + chunk;
cumulativeOutput =
nextOutput.length > LIVE_OUTPUT_MAX_BUFFER_CHARS
? nextOutput.slice(-LIVE_OUTPUT_MAX_BUFFER_CHARS)
: nextOutput;
};

const flushOutput = () => {
if (!hasPendingOutput || !updateOutput || this.params.is_background) {
return;
}

updateOutput(cumulativeOutput);
hasPendingOutput = false;
hasFlushedOutput = true;
lastUpdateTime = Date.now();
};
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.

high

To ensure that the last chunk of output is not stuck in the buffer when a command becomes silent but remains active, we should implement a trailing edge flush using a timer. This ensures that the UI is updated even if no further output events are received within the throttle interval. When implementing this, ensure the flush emits only the incremental delta instead of the full accumulated text to avoid redundant data transfer. The check for signal.aborted in flushOutput correctly utilizes the AbortSignal for cancellation safety. Note that while this check provides safety, it is still recommended to clear the timer in the finally block of the execute method to avoid unnecessary resource usage, although that block is outside the current diff scope.

References
  1. When implementing streaming message events, emit only the incremental delta instead of the full accumulated text to avoid redundant data transfer and potential display issues, particularly when a higher-level component is responsible for content accumulation.
  2. Asynchronous operations waiting for user input via the MessageBus should rely on the provided AbortSignal for cancellation, rather than implementing a separate timeout, to maintain consistency with existing patterns.

Comment on lines 603 to 605
if (shouldUpdate && !this.params.is_background) {
updateOutput(cumulativeOutput);
lastUpdateTime = Date.now();
flushOutput();
}
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.

high

Update the data handler to schedule a trailing flush if the current event is throttled. This ensures that sporadic output is eventually displayed as an incremental delta even if the process doesn't exit immediately.

References
  1. When implementing streaming message events, emit only the incremental delta instead of the full accumulated text to avoid redundant data transfer and potential display issues, particularly when a higher-level component is responsible for content accumulation.

@gemini-cli gemini-cli Bot added priority/p2 Important but can be addressed in a future release. area/core Issues related to User Interface, OS Support, Core Functionality help wanted We will accept PRs from all issues marked as "help wanted". Thanks for your support! labels May 12, 2026
Adds a setTimeout-based trailing flush so buffered text chunks are
rendered when a command goes silent but remains active. Addresses the
gemini-code-assist review on google-gemini#25461 / google-gemini#26955.

- New trailingFlushTimer hoisted to execute() scope and cleared in
  the existing finally block alongside timeoutTimer.
- scheduleTrailingFlush() is invoked whenever a text data event is
  throttle-blocked (within OUTPUT_UPDATE_INTERVAL_MS of the previous
  flush).
- flushOutput() now cancels any pending trailing timer up front, so
  synchronous flushes and the exit-path flush always win the race.
- Two new tests in shell.test.ts:
  * trailing flush fires when the command is silent for the throttle
    interval
  * exit cancels a scheduled trailing flush (no duplicate update)
- Existing "leading + throttle" test simplified to no longer depend
  on timer advances; final-state assertion now uses the exit flush.
@emersonbusson
Copy link
Copy Markdown
Author

Follow-up: trailing-edge flush (commit 011fdf0)

Pushed a follow-up commit that addresses both HIGH priority items from the
gemini-code-assist review on this PR.

What changed in 011fdf0:

  • Added scheduleTrailingFlush() — a setTimeout-based trailing flush that
    fires after OUTPUT_UPDATE_INTERVAL_MS whenever a text data event is
    throttle-blocked. Ensures buffered output reaches the UI even when a command
    emits a burst and then goes silent without exiting (e.g. long-running build
    that prints in batches).
  • cancelTrailingFlush() is called from flushOutput() so synchronous flushes
    (next eligible data event, exit, end of resultPromise) always cancel the
    pending timer — no duplicate updates.
  • trailingFlushTimer is hoisted to execute() scope and cleared in the
    existing finally block alongside timeoutTimer, so error paths don't leak
    the timer.
  • Two new regression tests in shell.test.ts:
    • should trailing-flush throttled text output when the command goes silent
    • should cancel the scheduled trailing flush when the command exits

The "incremental delta vs full buffer" suggestion in the review is a broader
architectural change to the updateOutput contract that I left out of scope
for this PR — happy to follow up in a separate one if maintainers think it's
worth it.

End-to-end validation

Built the bundle locally and ran it against a real workload (44 shell tool
calls, 222 stream events, exit code 0). No crashes, no UI regressions, throttle
behavior matches expectations in both burst and silent-then-resume scenarios.

@emersonbusson
Copy link
Copy Markdown
Author

Follow-up hardening pushed for the shell live-output path:

  • trailing flush now fires on the remaining throttle interval after the last visible update, so a quiet burst is surfaced on the next real trailing edge instead of waiting an extra full interval;
  • the bounded live text buffer now trims without starting on a low surrogate, avoiding malformed UTF-16 at the display boundary;
  • PTY AnsiOutput snapshots remain unthrottled;
  • I kept updateOutput as a replacement snapshot API rather than switching to incremental deltas, since delta semantics would change the live-output contract and broaden this PR beyond Shell tool text output causes UI jank on high-volume commands #25459.

Local validation:

  • git diff --check origin/main...HEAD
  • npm test -w @google/gemini-cli-core -- src/tools/shell.test.ts
  • npm run typecheck --workspace @google/gemini-cli-core
  • npm run lint --workspace @google/gemini-cli-core
  • npm run build --workspace @google/gemini-cli-core
  • Node 20: reran src/core/contentGenerator.test.ts, src/code_assist/setup.test.ts, and src/tools/shell.test.ts with local Google API/project env vars unset; all passed. I also ran npm run preflight under Node 20 with GEMINI_LINT_TEMP_DIR=/tmp/gemini-cli-linters; it passed build/lint/typecheck and the shell regression tests, then hit two existing env-sensitive core assertions only because those Google env vars were exported in my local shell.

The remaining expected external blocker is maintainer approval for Evaluate Steering & Regressions.

@scidomino scidomino enabled auto-merge May 13, 2026 19:31
@github-actions
Copy link
Copy Markdown

80 tests passed successfully on gemini-3-flash-preview.

🧠 Model Steering Guidance

This PR modifies files that affect the model's behavior (prompts, tools, or instructions).

  • ⚠️ Consider adding Evals: No behavioral evaluations (evals/*.eval.ts) were added or updated in this PR. Consider adding a test case to verify the new behavior and prevent regressions.

This is an automated guidance message triggered by steering logic signatures.

@scidomino
Copy link
Copy Markdown
Collaborator

Don't touch the PR while the tests are running

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core Issues related to User Interface, OS Support, Core Functionality 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.

Shell tool text output causes UI jank on high-volume commands

2 participants