diff --git a/package-lock.json b/package-lock.json index 3797ec58..75a4d84e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2154,6 +2154,21 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2750,6 +2765,34 @@ "dev": true, "license": "MIT" }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -2768,6 +2811,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -4724,6 +4779,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4807,6 +4877,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -5028,6 +5116,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -5722,6 +5825,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", + "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6434,6 +6555,18 @@ "dev": true, "license": "MIT" }, + "node_modules/run-applescript": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", + "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8157,6 +8290,7 @@ "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "lowlight": "^3.3.0", + "open": "^10.1.2", "react": "^18.3.1", "read-package-up": "^11.0.0", "shell-quote": "^1.8.2", diff --git a/packages/cli/package.json b/packages/cli/package.json index 0c088dc6..b421ad8f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,6 +40,7 @@ "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "lowlight": "^3.3.0", + "open": "^10.1.2", "react": "^18.3.1", "read-package-up": "^11.0.0", "shell-quote": "^1.8.2", diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index f7f1bb5e..a17fcd1e 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -21,6 +21,7 @@ vi.mock('node:fs/promises', () => ({ import { act, renderHook } from '@testing-library/react'; import { vi, describe, it, expect, beforeEach, Mock } from 'vitest'; +import open from 'open'; import { useSlashCommandProcessor } from './slashCommandProcessor.js'; import { MessageType } from '../types.js'; import * as memoryUtils from '../../config/memoryUtils.js'; @@ -39,6 +40,10 @@ vi.mock('./useShowMemoryCommand.js', () => ({ // Spy on the static method we want to mock const performAddMemoryEntrySpy = vi.spyOn(MemoryTool, 'performAddMemoryEntry'); +vi.mock('open', () => ({ + default: vi.fn(), +})); + describe('useSlashCommandProcessor', () => { let mockAddItem: ReturnType; let mockClearItems: ReturnType; @@ -58,7 +63,11 @@ describe('useSlashCommandProcessor', () => { mockOnDebugMessage = vi.fn(); mockOpenThemeDialog = vi.fn(); mockPerformMemoryRefresh = vi.fn().mockResolvedValue(undefined); - mockConfig = { getDebugMode: vi.fn(() => false) } as unknown as Config; + mockConfig = { + getDebugMode: vi.fn(() => false), + getSandbox: vi.fn(() => 'test-sandbox'), // Added mock + getModel: vi.fn(() => 'test-model'), // Added mock + } as unknown as Config; mockCorgiMode = vi.fn(); // Clear mocks for fsPromises if they were used directly or indirectly @@ -66,7 +75,8 @@ describe('useSlashCommandProcessor', () => { vi.mocked(fsPromises.writeFile).mockClear(); vi.mocked(fsPromises.mkdir).mockClear(); - performAddMemoryEntrySpy.mockReset(); // Reset the spy + performAddMemoryEntrySpy.mockReset(); + (open as Mock).mockClear(); vi.spyOn(memoryUtils, 'deleteLastMemoryEntry').mockImplementation(vi.fn()); vi.spyOn(memoryUtils, 'deleteAllAddedMemoryEntries').mockImplementation( vi.fn(), @@ -232,6 +242,155 @@ describe('useSlashCommandProcessor', () => { }); }); + describe('/bug command', () => { + const getExpectedUrl = ( + description?: string, + sandboxEnvVar?: string, + seatbeltProfileVar?: string, + ) => { + const cliVersion = process.env.npm_package_version || 'Unknown'; + const osVersion = `${process.platform} ${process.version}`; + let sandboxEnvStr = 'no sandbox'; + if (sandboxEnvVar && sandboxEnvVar !== 'sandbox-exec') { + sandboxEnvStr = sandboxEnvVar.replace(/^gemini-(?:code-)?/, ''); + } else if (sandboxEnvVar === 'sandbox-exec') { + sandboxEnvStr = `sandbox-exec (${seatbeltProfileVar || 'unknown'})`; + } + const modelVersion = 'test-model'; // From mockConfig + + const diagnosticInfo = ` +## Describe the bug +A clear and concise description of what the bug is. + +## Additional context +Add any other context about the problem here. + +## Diagnostic Information +* **CLI Version:** ${cliVersion} +* **Operating System:** ${osVersion} +* **Sandbox Environment:** ${sandboxEnvStr} +* **Model Version:** ${modelVersion} +`; + let url = + 'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.md'; + if (description) { + url += `&title=${encodeURIComponent(description)}`; + } + url += `&body=${encodeURIComponent(diagnosticInfo)}`; + return url; + }; + + it('should call open with the correct GitHub issue URL', async () => { + process.env.SANDBOX = 'gemini-sandbox'; + process.env.SEATBELT_PROFILE = 'test_profile'; + const { handleSlashCommand } = getProcessor(); + const bugDescription = 'This is a test bug'; + const expectedUrl = getExpectedUrl( + bugDescription, + process.env.SANDBOX, + process.env.SEATBELT_PROFILE, + ); + + await act(async () => { + handleSlashCommand(`/bug ${bugDescription}`); + }); + + expect(mockAddItem).toHaveBeenNthCalledWith( + 1, // User command + expect.objectContaining({ + type: MessageType.USER, + text: `/bug ${bugDescription}`, + }), + expect.any(Number), + ); + expect(mockAddItem).toHaveBeenNthCalledWith( + 2, // Info message + expect.objectContaining({ + type: MessageType.INFO, + text: `To submit your bug report, please open the following URL in your browser:\n${expectedUrl}`, + }), + expect.any(Number), // Timestamps are numbers from Date.now() + ); + expect(open).toHaveBeenCalledWith(expectedUrl); + delete process.env.SANDBOX; + delete process.env.SEATBELT_PROFILE; + }); + + it('should open the generic issue page if no bug description is provided', async () => { + process.env.SANDBOX = 'sandbox-exec'; + process.env.SEATBELT_PROFILE = 'minimal'; + const { handleSlashCommand } = getProcessor(); + const expectedUrl = getExpectedUrl( + undefined, + process.env.SANDBOX, + process.env.SEATBELT_PROFILE, + ); + await act(async () => { + handleSlashCommand('/bug '); + }); + expect(open).toHaveBeenCalledWith(expectedUrl); + expect(mockAddItem).toHaveBeenNthCalledWith( + 1, // User command + expect.objectContaining({ + type: MessageType.USER, + text: '/bug', // Ensure this matches the input + }), + expect.any(Number), + ); + expect(mockAddItem).toHaveBeenNthCalledWith( + 2, // Info message + expect.objectContaining({ + type: MessageType.INFO, + text: `To submit your bug report, please open the following URL in your browser:\n${expectedUrl}`, + }), + expect.any(Number), // Timestamps are numbers from Date.now() + ); + delete process.env.SANDBOX; + delete process.env.SEATBELT_PROFILE; + }); + + it('should handle errors when open fails', async () => { + // Test with no SANDBOX env var + delete process.env.SANDBOX; + delete process.env.SEATBELT_PROFILE; + const { handleSlashCommand } = getProcessor(); + const bugDescription = 'Another bug'; + const expectedUrl = getExpectedUrl(bugDescription); + const openError = new Error('Failed to open browser'); + (open as Mock).mockRejectedValue(openError); + + await act(async () => { + handleSlashCommand(`/bug ${bugDescription}`); + }); + + expect(open).toHaveBeenCalledWith(expectedUrl); + expect(mockAddItem).toHaveBeenNthCalledWith( + 1, // User command + expect.objectContaining({ + type: MessageType.USER, + text: `/bug ${bugDescription}`, + }), + expect.any(Number), + ); + expect(mockAddItem).toHaveBeenNthCalledWith( + 2, // Info message before open attempt + expect.objectContaining({ + type: MessageType.INFO, + text: `To submit your bug report, please open the following URL in your browser:\n${expectedUrl}`, + }), + expect.any(Number), // Timestamps are numbers from Date.now() + ); + expect(mockAddItem).toHaveBeenNthCalledWith( + 3, // Error message after open fails + expect.objectContaining({ + type: MessageType.ERROR, + text: `Could not open URL in browser: ${openError.message}`, + }), + expect.any(Number), // Timestamps are numbers from Date.now() + ); + }); + }); + describe('Unknown command', () => { it('should show an error for a general unknown command', async () => { const { handleSlashCommand } = getProcessor(); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 095f4ad7..9f6a6d5e 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -6,6 +6,7 @@ import { useCallback, useMemo } from 'react'; import { type PartListUnion } from '@google/genai'; +import open from 'open'; import { UseHistoryManagerReturn } from './useHistoryManager.js'; import { Config } from '@gemini-code/server'; import { Message, MessageType, HistoryItemWithoutId } from '../types.js'; @@ -138,6 +139,71 @@ export const useSlashCommandProcessor = ( toggleCorgiMode(); }, }, + { + name: 'bug', + description: 'Submit a bug report.', + action: (_mainCommand, _subCommand, args) => { + let bugDescription = _subCommand || ''; + if (args) { + bugDescription += ` ${args}`; + } + bugDescription = bugDescription.trim(); + + const cliVersion = process.env.npm_package_version || 'Unknown'; + const osVersion = `${process.platform} ${process.version}`; + let sandboxEnv = 'no sandbox'; + if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') { + sandboxEnv = process.env.SANDBOX.replace(/^gemini-(?:code-)?/, ''); + } else if (process.env.SANDBOX === 'sandbox-exec') { + sandboxEnv = `sandbox-exec (${process.env.SEATBELT_PROFILE || 'unknown'})`; + } + const modelVersion = config?.getModel() || 'Unknown'; + + const diagnosticInfo = ` +## Describe the bug +A clear and concise description of what the bug is. + +## Additional context +Add any other context about the problem here. + +## Diagnostic Information +* **CLI Version:** ${cliVersion} +* **Operating System:** ${osVersion} +* **Sandbox Environment:** ${sandboxEnv} +* **Model Version:** ${modelVersion} +`; + + let bugReportUrl = + 'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.md'; + if (bugDescription) { + const encodedArgs = encodeURIComponent(bugDescription); + bugReportUrl += `&title=${encodedArgs}`; + } + const encodedBody = encodeURIComponent(diagnosticInfo); + bugReportUrl += `&body=${encodedBody}`; + + addMessage({ + type: MessageType.INFO, + content: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}`, + timestamp: new Date(), + }); + // Open the URL in the default browser + (async () => { + try { + await open(bugReportUrl); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + addMessage({ + type: MessageType.ERROR, + content: `Could not open URL in browser: ${errorMessage}`, + timestamp: new Date(), + }); + } + })(); + }, + }, + { name: 'quit', altName: 'exit', @@ -159,6 +225,7 @@ export const useSlashCommandProcessor = ( addMemoryAction, addMessage, toggleCorgiMode, + config, // Added config to dependency array ], );