diff --git a/package-lock.json b/package-lock.json
index a0e554676c..69fb107bc6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -598,6 +598,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -621,6 +622,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -2426,6 +2428,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2606,6 +2609,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2639,6 +2643,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -3007,6 +3012,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -3040,6 +3046,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1"
@@ -3092,6 +3099,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1",
@@ -3807,6 +3815,7 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4339,6 +4348,7 @@
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4349,6 +4359,7 @@
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@@ -4626,6 +4637,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5393,6 +5405,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5756,8 +5769,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/array-includes": {
"version": "3.1.9",
@@ -7003,7 +7015,6 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"safe-buffer": "5.2.1"
},
@@ -8051,6 +8062,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8640,7 +8652,6 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -8650,7 +8661,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -8660,7 +8670,6 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -8890,7 +8899,6 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
@@ -8909,7 +8917,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -8918,15 +8925,13 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/finalhandler/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -10143,6 +10148,7 @@
"resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz",
"integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.0",
"ansi-escapes": "^7.0.0",
@@ -13279,8 +13285,7 @@
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/path-type": {
"version": "3.0.0",
@@ -13814,6 +13819,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13824,6 +13830,7 @@
"integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -13857,6 +13864,7 @@
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -15920,6 +15928,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -16130,7 +16139,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
- "license": "0BSD"
+ "license": "0BSD",
+ "peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16138,6 +16148,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16322,6 +16333,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16483,7 +16495,6 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">= 0.4.0"
}
@@ -16539,6 +16550,7 @@
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16655,6 +16667,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -16668,6 +16681,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -17419,6 +17433,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -17960,6 +17975,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
diff --git a/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap b/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap
new file mode 100644
index 0000000000..5d41472b89
--- /dev/null
+++ b/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap
@@ -0,0 +1,8 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = `
+"Use mock tool
+Use mock tool again
+Finished.
+"
+`;
diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts
index da5d097c64..cff544305d 100644
--- a/packages/cli/src/nonInteractiveCli.test.ts
+++ b/packages/cli/src/nonInteractiveCli.test.ts
@@ -190,6 +190,9 @@ describe('runNonInteractive', () => {
}
}
+ const getWrittenOutput = () =>
+ processStdoutSpy.mock.calls.map((c) => c[0]).join('');
+
it('should process input and write text output', async () => {
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Hello' },
@@ -215,9 +218,7 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-1',
);
- expect(processStdoutSpy).toHaveBeenCalledWith('Hello');
- expect(processStdoutSpy).toHaveBeenCalledWith(' World');
- expect(processStdoutSpy).toHaveBeenCalledWith('\n');
+ expect(getWrittenOutput()).toBe('Hello World\n');
expect(mockShutdownTelemetry).toHaveBeenCalled();
});
@@ -285,8 +286,77 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-2',
);
- expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
- expect(processStdoutSpy).toHaveBeenCalledWith('\n');
+ expect(getWrittenOutput()).toBe('Final answer\n');
+ });
+
+ it('should write a single newline between sequential text outputs from the model', async () => {
+ // This test simulates a multi-turn conversation to ensure that a single newline
+ // is printed between each block of text output from the model.
+
+ // 1. Define the tool requests that the model will ask the CLI to run.
+ const toolCallEvent: ServerGeminiStreamEvent = {
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId: 'mock-tool',
+ name: 'mockTool',
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'prompt-id-multi',
+ },
+ };
+
+ // 2. Mock the execution of the tools. We just need them to succeed.
+ mockCoreExecuteToolCall.mockResolvedValue({
+ status: 'success',
+ request: toolCallEvent.value, // This is generic enough for both calls
+ tool: {} as AnyDeclarativeTool,
+ invocation: {} as AnyToolInvocation,
+ response: {
+ responseParts: [],
+ callId: 'mock-tool',
+ },
+ });
+
+ // 3. Define the sequence of events streamed from the mock model.
+ // Turn 1: Model outputs text, then requests a tool call.
+ const modelTurn1: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Use mock tool' },
+ toolCallEvent,
+ ];
+ // Turn 2: Model outputs more text, then requests another tool call.
+ const modelTurn2: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Use mock tool again' },
+ toolCallEvent,
+ ];
+ // Turn 3: Model outputs a final answer.
+ const modelTurn3: ServerGeminiStreamEvent[] = [
+ { type: GeminiEventType.Content, value: 'Finished.' },
+ {
+ type: GeminiEventType.Finished,
+ value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
+ },
+ ];
+
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(createStreamFromEvents(modelTurn1))
+ .mockReturnValueOnce(createStreamFromEvents(modelTurn2))
+ .mockReturnValueOnce(createStreamFromEvents(modelTurn3));
+
+ // 4. Run the command.
+ await runNonInteractive(
+ mockConfig,
+ mockSettings,
+ 'Use mock tool multiple times',
+ 'prompt-id-multi',
+ );
+
+ // 5. Verify the output.
+ // The rendered output should contain the text from each turn, separated by a
+ // single newline, with a final newline at the end.
+ expect(getWrittenOutput()).toMatchSnapshot();
+
+ // Also verify the tools were called as expected.
+ expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(2);
});
it('should handle error during tool execution and should send error back to the model', async () => {
@@ -369,7 +439,7 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-3',
);
- expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.');
+ expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
});
it('should exit with error if sendMessageStream throws initially', async () => {
@@ -444,9 +514,7 @@ describe('runNonInteractive', () => {
'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.',
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
- expect(processStdoutSpy).toHaveBeenCalledWith(
- "Sorry, I can't find that tool.",
- );
+ expect(getWrittenOutput()).toBe("Sorry, I can't find that tool.\n");
});
it('should exit when max session turns are exceeded', async () => {
@@ -506,7 +574,7 @@ describe('runNonInteractive', () => {
);
// 6. Assert the final output is correct
- expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.');
+ expect(getWrittenOutput()).toBe('Summary complete.\n');
});
it('should process input and write JSON output with stats', async () => {
@@ -850,7 +918,7 @@ describe('runNonInteractive', () => {
'prompt-id-slash',
);
- expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
+ expect(getWrittenOutput()).toBe('Response from command\n');
});
it('should throw FatalInputError if a command requires confirmation', async () => {
@@ -905,7 +973,7 @@ describe('runNonInteractive', () => {
'prompt-id-unknown',
);
- expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
+ expect(getWrittenOutput()).toBe('Response to unknown\n');
});
it('should throw for unhandled command result types', async () => {
@@ -962,7 +1030,7 @@ describe('runNonInteractive', () => {
expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2');
- expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged');
+ expect(getWrittenOutput()).toBe('Acknowledged\n');
});
it('should instantiate CommandService with correct loaders for slash commands', async () => {
@@ -1073,7 +1141,7 @@ describe('runNonInteractive', () => {
expect.objectContaining({ name: 'ShellTool' }),
expect.any(AbortSignal),
);
- expect(processStdoutSpy).toHaveBeenCalledWith('file.txt');
+ expect(getWrittenOutput()).toBe('file.txt\n');
});
describe('CoreEvents Integration', () => {
diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts
index 7b89732b10..efb0e3186d 100644
--- a/packages/cli/src/nonInteractiveCli.ts
+++ b/packages/cli/src/nonInteractiveCli.ts
@@ -40,6 +40,7 @@ import {
handleCancellationError,
handleMaxTurnsExceededError,
} from './utils/errors.js';
+import { TextOutput } from './ui/utils/textOutput.js';
export async function runNonInteractive(
config: Config,
@@ -52,6 +53,7 @@ export async function runNonInteractive(
stderr: true,
debugMode: config.getDebugMode(),
});
+ const textOutput = new TextOutput();
const handleUserFeedback = (payload: UserFeedbackPayload) => {
const prefix = payload.severity.toUpperCase();
@@ -183,7 +185,9 @@ export async function runNonInteractive(
} else if (config.getOutputFormat() === OutputFormat.JSON) {
responseText += event.value;
} else {
- process.stdout.write(event.value);
+ if (event.value) {
+ textOutput.write(event.value);
+ }
}
} else if (event.type === GeminiEventType.ToolCallRequest) {
if (streamFormatter) {
@@ -220,6 +224,7 @@ export async function runNonInteractive(
}
if (toolCallRequests.length > 0) {
+ textOutput.ensureTrailingNewline();
const toolResponseParts: Part[] = [];
const completedToolCalls: CompletedToolCall[] = [];
@@ -297,9 +302,9 @@ export async function runNonInteractive(
} else if (config.getOutputFormat() === OutputFormat.JSON) {
const formatter = new JsonFormatter();
const stats = uiTelemetryService.getMetrics();
- process.stdout.write(formatter.format(responseText, stats));
+ textOutput.write(formatter.format(responseText, stats));
} else {
- process.stdout.write('\n'); // Ensure a final newline
+ textOutput.ensureTrailingNewline(); // Ensure a final newline
}
return;
}
diff --git a/packages/cli/src/ui/utils/__snapshots__/textOutput.test.ts.snap b/packages/cli/src/ui/utils/__snapshots__/textOutput.test.ts.snap
new file mode 100644
index 0000000000..4618d553b3
--- /dev/null
+++ b/packages/cli/src/ui/utils/__snapshots__/textOutput.test.ts.snap
@@ -0,0 +1,23 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`TextOutput > should correctly handle ANSI escape codes when determining line breaks 1`] = `
+"[34mhello[39m
+[1mworld[22m[34m
+[39mnext"
+`;
+
+exports[`TextOutput > should handle ANSI codes that do not end with a newline 1`] = `
+"hello[34m
+world"
+`;
+
+exports[`TextOutput > should handle a sequence of calls correctly 1`] = `
+"first
+second part
+third"
+`;
+
+exports[`TextOutput > should handle empty strings with ANSI codes 1`] = `
+"hello[34m[39m
+world"
+`;
diff --git a/packages/cli/src/ui/utils/textOutput.test.ts b/packages/cli/src/ui/utils/textOutput.test.ts
new file mode 100644
index 0000000000..b8a0882d64
--- /dev/null
+++ b/packages/cli/src/ui/utils/textOutput.test.ts
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+///
+
+import type { MockInstance } from 'vitest';
+import { vi } from 'vitest';
+import { TextOutput } from './textOutput.js';
+
+describe('TextOutput', () => {
+ let stdoutSpy: MockInstance;
+ let textOutput: TextOutput;
+
+ beforeEach(() => {
+ stdoutSpy = vi
+ .spyOn(process.stdout, 'write')
+ .mockImplementation(() => true);
+ textOutput = new TextOutput();
+ });
+
+ afterEach(() => {
+ stdoutSpy.mockRestore();
+ });
+
+ const getWrittenOutput = () => stdoutSpy.mock.calls.map((c) => c[0]).join('');
+
+ it('write() should call process.stdout.write', () => {
+ textOutput.write('hello');
+ expect(stdoutSpy).toHaveBeenCalledWith('hello');
+ });
+
+ it('write() should not call process.stdout.write for empty strings', () => {
+ textOutput.write('');
+ expect(stdoutSpy).not.toHaveBeenCalled();
+ });
+
+ it('writeOnNewLine() should not add a newline if the last char was a newline', () => {
+ // Default state starts at the beginning of a line
+ textOutput.writeOnNewLine('hello');
+ expect(getWrittenOutput()).toBe('hello');
+ });
+
+ it('writeOnNewLine() should add a newline if the last char was not a newline', () => {
+ textOutput.write('previous');
+ textOutput.writeOnNewLine('hello');
+ expect(getWrittenOutput()).toBe('previous\nhello');
+ });
+
+ it('ensureTrailingNewline() should add a newline if one is missing', () => {
+ textOutput.write('hello');
+ textOutput.ensureTrailingNewline();
+ expect(getWrittenOutput()).toBe('hello\n');
+ });
+
+ it('ensureTrailingNewline() should not add a newline if one already exists', () => {
+ textOutput.write('hello\n');
+ textOutput.ensureTrailingNewline();
+ expect(getWrittenOutput()).toBe('hello\n');
+ });
+
+ it('should handle a sequence of calls correctly', () => {
+ textOutput.write('first');
+ textOutput.writeOnNewLine('second');
+ textOutput.write(' part');
+ textOutput.ensureTrailingNewline();
+ textOutput.ensureTrailingNewline(); // second call should do nothing
+ textOutput.write('third');
+
+ expect(getWrittenOutput()).toMatchSnapshot();
+ });
+
+ it('should correctly handle ANSI escape codes when determining line breaks', () => {
+ const blue = (s: string) => `\u001b[34m${s}\u001b[39m`;
+ const bold = (s: string) => `\u001b[1m${s}\u001b[22m`;
+
+ textOutput.write(blue('hello'));
+ textOutput.writeOnNewLine(bold('world'));
+ textOutput.write(blue('\n'));
+ textOutput.writeOnNewLine('next');
+
+ expect(getWrittenOutput()).toMatchSnapshot();
+ });
+
+ it('should handle empty strings with ANSI codes', () => {
+ textOutput.write('hello');
+ textOutput.write('\u001b[34m\u001b[39m'); // Empty blue string
+ textOutput.writeOnNewLine('world');
+ expect(getWrittenOutput()).toMatchSnapshot();
+ });
+
+ it('should handle ANSI codes that do not end with a newline', () => {
+ textOutput.write('hello\u001b[34m');
+ textOutput.writeOnNewLine('world');
+ expect(getWrittenOutput()).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/utils/textOutput.ts b/packages/cli/src/ui/utils/textOutput.ts
new file mode 100644
index 0000000000..420f774044
--- /dev/null
+++ b/packages/cli/src/ui/utils/textOutput.ts
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * A utility to manage writing text to stdout, ensuring that newlines
+ * are handled consistently and robustly across the application.
+ */
+
+import stripAnsi from 'strip-ansi';
+
+export class TextOutput {
+ private atStartOfLine = true;
+
+ /**
+ * Writes a string to stdout.
+ * @param str The string to write.
+ */
+ write(str: string): void {
+ if (str.length === 0) {
+ return;
+ }
+ process.stdout.write(str);
+ const strippedStr = stripAnsi(str);
+ if (strippedStr.length > 0) {
+ this.atStartOfLine = strippedStr.endsWith('\n');
+ }
+ }
+
+ /**
+ * Writes a string to stdout, ensuring it starts on a new line.
+ * If the previous output did not end with a newline, one will be added.
+ * This prevents adding extra blank lines if a newline already exists.
+ * @param str The string to write.
+ */
+ writeOnNewLine(str: string): void {
+ if (!this.atStartOfLine) {
+ this.write('\n');
+ }
+ this.write(str);
+ }
+
+ /**
+ * Ensures that the output ends with a newline. If the last character
+ * written was not a newline, one will be added.
+ */
+ ensureTrailingNewline(): void {
+ if (!this.atStartOfLine) {
+ this.write('\n');
+ }
+ }
+}