refactor(stdio): always patch stdout and use createWorkingStdio for clean output (#14159)

This commit is contained in:
Allen Hutchison
2025-12-02 15:08:25 -08:00
committed by GitHub
parent 2d935b3798
commit 828afe113e
12 changed files with 31 additions and 24 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
), ),
), ),
patchStdio: vi.fn(() => () => {}), patchStdio: vi.fn(() => () => {}),
createInkStdio: vi.fn(() => ({ createWorkingStdio: vi.fn(() => ({
stdout: { stdout: {
write: vi.fn((...args) => write: vi.fn((...args) =>
process.stdout.write( process.stdout.write(
+2 -2
View File
@@ -47,7 +47,7 @@ import {
recordSlowRender, recordSlowRender,
coreEvents, coreEvents,
CoreEvent, CoreEvent,
createInkStdio, createWorkingStdio,
patchStdio, patchStdio,
writeToStdout, writeToStdout,
writeToStderr, writeToStderr,
@@ -203,7 +203,7 @@ export async function startInteractiveUI(
consolePatcher.patch(); consolePatcher.patch();
registerCleanup(consolePatcher.cleanup); registerCleanup(consolePatcher.cleanup);
const { stdout: inkStdout, stderr: inkStderr } = createInkStdio(); const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
// Create wrapper component to use hooks inside render // Create wrapper component to use hooks inside render
const AppWrapper = () => { const AppWrapper = () => {
+1 -1
View File
@@ -24,7 +24,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
...actual, ...actual,
writeToStdout: vi.fn(), writeToStdout: vi.fn(),
patchStdio: vi.fn(() => () => {}), patchStdio: vi.fn(() => () => {}),
createInkStdio: vi.fn(() => ({ createWorkingStdio: vi.fn(() => ({
stdout: { stdout: {
write: vi.fn(), write: vi.fn(),
columns: 80, columns: 80,
@@ -68,6 +68,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
getMetrics: vi.fn(), getMetrics: vi.fn(),
}, },
coreEvents: mockCoreEvents, coreEvents: mockCoreEvents,
createWorkingStdio: vi.fn(() => ({
stdout: process.stdout,
stderr: process.stderr,
})),
}; };
}); });
+3 -1
View File
@@ -28,6 +28,7 @@ import {
debugLogger, debugLogger,
coreEvents, coreEvents,
CoreEvent, CoreEvent,
createWorkingStdio,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { Content, Part } from '@google/genai'; import type { Content, Part } from '@google/genai';
@@ -70,7 +71,8 @@ export async function runNonInteractive({
coreEvents.emitConsoleLog(msg.type, msg.content); coreEvents.emitConsoleLog(msg.type, msg.content);
}, },
}); });
const textOutput = new TextOutput(); const { stdout: workingStdout } = createWorkingStdio();
const textOutput = new TextOutput(workingStdout);
const handleUserFeedback = (payload: UserFeedbackPayload) => { const handleUserFeedback = (payload: UserFeedbackPayload) => {
const prefix = payload.severity.toUpperCase(); const prefix = payload.severity.toUpperCase();
+1 -1
View File
@@ -65,7 +65,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
), ),
), ),
patchStdio: vi.fn(() => () => {}), patchStdio: vi.fn(() => () => {}),
createInkStdio: vi.fn(() => ({ createWorkingStdio: vi.fn(() => ({
stdout: process.stdout, stdout: process.stdout,
stderr: process.stderr, stderr: process.stderr,
})), })),
+6 -1
View File
@@ -13,6 +13,11 @@ import stripAnsi from 'strip-ansi';
export class TextOutput { export class TextOutput {
private atStartOfLine = true; private atStartOfLine = true;
private outputStream: NodeJS.WriteStream;
constructor(outputStream: NodeJS.WriteStream = process.stdout) {
this.outputStream = outputStream;
}
/** /**
* Writes a string to stdout. * Writes a string to stdout.
@@ -22,7 +27,7 @@ export class TextOutput {
if (str.length === 0) { if (str.length === 0) {
return; return;
} }
process.stdout.write(str); this.outputStream.write(str);
const strippedStr = stripAnsi(str); const strippedStr = stripAnsi(str);
if (strippedStr.length > 0) { if (strippedStr.length > 0) {
this.atStartOfLine = strippedStr.endsWith('\n'); this.atStartOfLine = strippedStr.endsWith('\n');
@@ -30,6 +30,7 @@ import {
debugLogger, debugLogger,
ReadManyFilesTool, ReadManyFilesTool,
getEffectiveModel, getEffectiveModel,
createWorkingStdio,
startupProfiler, startupProfiler,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import * as acp from './acp.js'; import * as acp from './acp.js';
@@ -51,15 +52,10 @@ export async function runZedIntegration(
settings: LoadedSettings, settings: LoadedSettings,
argv: CliArgs, argv: CliArgs,
) { ) {
const stdout = Writable.toWeb(process.stdout) as WritableStream; const { stdout: workingStdout } = createWorkingStdio();
const stdout = Writable.toWeb(workingStdout) as WritableStream;
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>; const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
// Stdout is used to send messages to the client, so console.log/console.info
// messages to stderr so that they don't interfere with ACP.
console.log = console.error;
console.info = console.error;
console.debug = console.error;
new acp.AgentSideConnection( new acp.AgentSideConnection(
(client: acp.Client) => new GeminiAgent(config, settings, argv, client), (client: acp.Client) => new GeminiAgent(config, settings, argv, client),
stdout, stdout,
+1 -1
View File
@@ -48,7 +48,7 @@ vi.mock('../utils/browser.js', () => ({
vi.mock('../utils/stdio.js', () => ({ vi.mock('../utils/stdio.js', () => ({
writeToStdout: vi.fn(), writeToStdout: vi.fn(),
writeToStderr: vi.fn(), writeToStderr: vi.fn(),
createInkStdio: vi.fn(() => ({ createWorkingStdio: vi.fn(() => ({
stdout: process.stdout, stdout: process.stdout,
stderr: process.stderr, stderr: process.stderr,
})), })),
+2 -2
View File
@@ -33,7 +33,7 @@ import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js';
import { debugLogger } from '../utils/debugLogger.js'; import { debugLogger } from '../utils/debugLogger.js';
import { import {
writeToStdout, writeToStdout,
createInkStdio, createWorkingStdio,
writeToStderr, writeToStderr,
} from '../utils/stdio.js'; } from '../utils/stdio.js';
import { import {
@@ -334,7 +334,7 @@ async function authWithUserCode(client: OAuth2Client): Promise<boolean> {
const code = await new Promise<string>((resolve, _) => { const code = await new Promise<string>((resolve, _) => {
const rl = readline.createInterface({ const rl = readline.createInterface({
input: process.stdin, input: process.stdin,
output: createInkStdio().stdout, output: createWorkingStdio().stdout,
terminal: true, terminal: true,
}); });
+5 -5
View File
@@ -5,7 +5,7 @@
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { patchStdio, createInkStdio } from './stdio.js'; import { patchStdio, createWorkingStdio } from './stdio.js';
import { coreEvents } from './events.js'; import { coreEvents } from './events.js';
vi.mock('./events.js', () => ({ vi.mock('./events.js', () => ({
@@ -53,14 +53,14 @@ describe('stdio utils', () => {
expect(process.stderr.write).toBe(originalStderrWrite); expect(process.stderr.write).toBe(originalStderrWrite);
}); });
it('createInkStdio writes to real stdout/stderr bypassing patch', () => { it('createWorkingStdio writes to real stdout/stderr bypassing patch', () => {
const cleanup = patchStdio(); const cleanup = patchStdio();
const { stdout: inkStdout, stderr: inkStderr } = createInkStdio(); const { stdout, stderr } = createWorkingStdio();
inkStdout.write('ink stdout'); stdout.write('working stdout');
expect(coreEvents.emitOutput).not.toHaveBeenCalled(); expect(coreEvents.emitOutput).not.toHaveBeenCalled();
inkStderr.write('ink stderr'); stderr.write('working stderr');
expect(coreEvents.emitOutput).not.toHaveBeenCalled(); expect(coreEvents.emitOutput).not.toHaveBeenCalled();
cleanup(); cleanup();
+2 -2
View File
@@ -80,9 +80,9 @@ export function patchStdio(): () => void {
/** /**
* Creates proxies for process.stdout and process.stderr that use the real write methods * Creates proxies for process.stdout and process.stderr that use the real write methods
* (writeToStdout and writeToStderr) bypassing any monkey patching. * (writeToStdout and writeToStderr) bypassing any monkey patching.
* This is used by Ink to render to the real output. * This is used to write to the real output even when stdio is patched.
*/ */
export function createInkStdio() { export function createWorkingStdio() {
const inkStdout = new Proxy(process.stdout, { const inkStdout = new Proxy(process.stdout, {
get(target, prop, receiver) { get(target, prop, receiver) {
if (prop === 'write') { if (prop === 'write') {