diff --git a/.github/workflows/eval-guidance.yml b/.github/workflows/eval-guidance.yml new file mode 100644 index 0000000000..e1f1ab3168 --- /dev/null +++ b/.github/workflows/eval-guidance.yml @@ -0,0 +1,69 @@ +name: 'Evals: PR Guidance' + +on: + pull_request: + paths: + - 'packages/core/src/**/*.ts' + - '!**/*.test.ts' + - '!**/*.test.tsx' + +permissions: + pull-requests: 'write' + contents: 'read' + +jobs: + provide-guidance: + name: 'Model Steering Guidance' + runs-on: 'ubuntu-latest' + if: "github.repository == 'google-gemini/gemini-cli'" + steps: + - name: 'Checkout' + uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 'Set up Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Detect Steering Changes' + id: 'detect' + run: | + STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only) + echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT" + + - name: 'Analyze PR Content' + if: "steps.detect.outputs.STEERING_DETECTED == 'true'" + id: 'analysis' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: | + # Check for behavioral eval changes + EVAL_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^evals/" || true) + if [ -z "$EVAL_CHANGES" ]; then + echo "MISSING_EVALS=true" >> "$GITHUB_OUTPUT" + fi + + # Check if user is a maintainer (has write/admin access) + USER_PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission --jq '.permission') + if [[ "$USER_PERMISSION" == "admin" || "$USER_PERMISSION" == "write" ]]; then + echo "IS_MAINTAINER=true" >> "$GITHUB_OUTPUT" + fi + + - name: 'Post Guidance Comment' + if: "steps.detect.outputs.STEERING_DETECTED == 'true'" + uses: 'thollander/actions-comment-pull-request@65f9e5c9a1f2cd378bd74b2e057c9736982a8e74' # ratchet:thollander/actions-comment-pull-request@v3 + with: + comment-tag: 'eval-guidance-bot' + message: | + ### ๐Ÿง  Model Steering Guidance + + This PR modifies files that affect the model's behavior (prompts, tools, or instructions). + + ${{ steps.analysis.outputs.MISSING_EVALS == 'true' && '- โš ๏ธ **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.' || '' }} + ${{ steps.analysis.outputs.IS_MAINTAINER == 'true' && '- ๐Ÿš€ **Maintainer Reminder:** Please ensure that these changes do not regress results on benchmark evals before merging.' || '' }} + + --- + *This is an automated guidance message triggered by steering logic signatures.* diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 9b4945cd68..c784667ef0 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1535,7 +1535,7 @@ their corresponding top-level category object in your `settings.json` file. - **`experimental.enableAgents`** (boolean): - **Description:** Enable local and remote subagents. - - **Default:** `true` + - **Default:** `false` - **Requires restart:** Yes - **`experimental.worktrees`** (boolean): diff --git a/integration-tests/browser-policy.test.ts b/integration-tests/browser-policy.test.ts index f533cb3f5e..bb66b10aab 100644 --- a/integration-tests/browser-policy.test.ts +++ b/integration-tests/browser-policy.test.ts @@ -63,6 +63,9 @@ describe.skipIf(!chromeAvailable)('browser-policy', () => { rig.setup('browser-policy-skip-confirmation', { fakeResponsesPath: join(__dirname, 'browser-policy.responses'), settings: { + experimental: { + enableAgents: true, + }, agents: { overrides: { browser_agent: { @@ -180,6 +183,9 @@ priority = 200 rig.setup('browser-session-warning', { fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'), settings: { + experimental: { + enableAgents: true, + }, general: { enableAutoUpdateNotification: false, }, diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts index cfe77311ea..370c859944 100644 --- a/packages/a2a-server/src/config/config.test.ts +++ b/packages/a2a-server/src/config/config.test.ts @@ -341,11 +341,11 @@ describe('loadConfig', () => { ); }); - it('should default enableAgents to true when not provided', async () => { + it('should default enableAgents to false when not provided', async () => { await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(Config).toHaveBeenCalledWith( expect.objectContaining({ - enableAgents: true, + enableAgents: false, }), ); }); diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 9474c4d9c5..97243c88d8 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -127,7 +127,7 @@ export async function loadConfig( interactive: !isHeadlessMode(), enableInteractiveShell: !isHeadlessMode(), ptyInfo: 'auto', - enableAgents: settings.experimental?.enableAgents ?? true, + enableAgents: settings.experimental?.enableAgents ?? false, }; const fileService = new FileDiscoveryService(workspaceDir, { diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index c358cd65aa..9b643396ae 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -400,7 +400,7 @@ describe('SettingsSchema', () => { expect(setting).toBeDefined(); expect(setting.type).toBe('boolean'); expect(setting.category).toBe('Experimental'); - expect(setting.default).toBe(true); + expect(setting.default).toBe(false); expect(setting.requiresRestart).toBe(true); expect(setting.showInDialog).toBe(false); expect(setting.description).toBe('Enable local and remote subagents.'); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 277dcfdcb9..00ea1b6102 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1922,7 +1922,7 @@ const SETTINGS_SCHEMA = { label: 'Enable Agents', category: 'Experimental', requiresRestart: true, - default: true, + default: false, description: 'Enable local and remote subagents.', showInDialog: false, }, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 9f298af0db..4a6d876281 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -215,12 +215,36 @@ export async function main() { loadSettingsHandle?.end(); // If a worktree is requested and enabled, set it up early. + // This must be awaited before any other async tasks that depend on CWD (like loadCliConfig) + // because setupWorktree calls process.chdir(). const requestedWorktree = cliConfig.getRequestedWorktreeName(settings); let worktreeInfo: WorktreeInfo | undefined; if (requestedWorktree !== undefined) { + const worktreeHandle = startupProfiler.start('setup_worktree'); worktreeInfo = await setupWorktree(requestedWorktree || undefined); + worktreeHandle?.end(); } + const cleanupOpsHandle = startupProfiler.start('cleanup_ops'); + Promise.all([ + cleanupCheckpoints(), + cleanupToolOutputFiles(settings.merged), + cleanupBackgroundLogs(), + ]) + .catch((e) => { + debugLogger.error('Early cleanup failed:', e); + }) + .finally(() => { + cleanupOpsHandle?.end(); + }); + + const parseArgsHandle = startupProfiler.start('parse_arguments'); + const argvPromise = parseArguments(settings.merged).finally(() => { + parseArgsHandle?.end(); + }); + + const rawStartupWarningsPromise = getStartupWarnings(); + // Report settings errors once during startup settings.errors.forEach((error) => { coreEvents.emitFeedback('warning', error.message); @@ -234,15 +258,7 @@ export async function main() { ); }); - await Promise.all([ - cleanupCheckpoints(), - cleanupToolOutputFiles(settings.merged), - cleanupBackgroundLogs(), - ]); - - const parseArgsHandle = startupProfiler.start('parse_arguments'); - const argv = await parseArguments(settings.merged); - parseArgsHandle?.end(); + const argv = await argvPromise; if ( (argv.allowedTools && argv.allowedTools.length > 0) || @@ -469,12 +485,10 @@ export async function main() { await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit); }); - // Cleanup sessions after config initialization - try { - await cleanupExpiredSessions(config, settings.merged); - } catch (e) { + // Launch cleanup expired sessions as a background task + cleanupExpiredSessions(config, settings.merged).catch((e) => { debugLogger.error('Failed to cleanup expired sessions:', e); - } + }); if (config.getListExtensions()) { debugLogger.log('Installed extensions:'); @@ -526,7 +540,9 @@ export async function main() { }); } + const terminalHandle = startupProfiler.start('setup_terminal'); await setupTerminalAndTheme(config, settings); + terminalHandle?.end(); const initAppHandle = startupProfiler.start('initialize_app'); const initializationResult = await initializeApp(config, settings); @@ -550,7 +566,7 @@ export async function main() { isAlternateBufferEnabled(config), config.getScreenReader(), ); - const rawStartupWarnings = await getStartupWarnings(); + const rawStartupWarnings = await rawStartupWarningsPromise; const startupWarnings: StartupWarning[] = [ ...rawStartupWarnings.map((message) => ({ id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`, diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index dbbe84ca90..712a07c1d5 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -11,7 +11,11 @@ import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs'; import { AppContainer } from '../ui/AppContainer.js'; -import { renderWithProviders, type RenderInstance } from './render.js'; +import { + renderWithProviders, + type RenderInstance, + persistentStateMock, +} from './render.js'; import { makeFakeConfig, type Config, @@ -194,6 +198,11 @@ export class AppRig { } async initialize() { + persistentStateMock.setData({ + terminalSetupPromptShown: true, + tipsShown: 10, + }); + this.setupEnvironment(); resetSettingsCacheForTesting(); this.settings = this.createRigSettings(); @@ -240,6 +249,8 @@ export class AppRig { private setupEnvironment() { // Stub environment variables to avoid interference from developer's machine vi.stubEnv('GEMINI_CLI_HOME', this.testDir); + vi.stubEnv('TERM_PROGRAM', 'other'); + vi.stubEnv('VSCODE_GIT_IPC_HANDLE', ''); if (this.options.fakeResponsesPath) { vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); MockShellExecutionService.setPassthrough(false); @@ -305,7 +316,6 @@ export class AppRig { const newContentGeneratorConfig = { authType: authMethod, - proxy: gcConfig.getProxy(), apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key', }; diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap index 9e1d66df01..1dec76271a 100644 --- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -2,10 +2,13 @@ exports[`App > Snapshots > renders default layout correctly 1`] = ` " - โ–โ–œโ–„ Gemini CLI v1.2.3 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v1.2.3 + Tips for getting started: @@ -31,9 +34,6 @@ Tips for getting started: - - - @@ -47,10 +47,13 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = ` "Notifications Footer - โ–โ–œโ–„ Gemini CLI v1.2.3 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v1.2.3 + Tips for getting started: @@ -64,12 +67,12 @@ Composer exports[`App > Snapshots > renders with dialogs visible 1`] = ` " - โ–โ–œโ–„ Gemini CLI v1.2.3 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ - + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + Gemini CLI v1.2.3 @@ -107,10 +110,13 @@ DialogManager exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = ` " - โ–โ–œโ–„ Gemini CLI v1.2.3 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v1.2.3 + Tips for getting started: @@ -140,9 +146,6 @@ HistoryItemDisplay - - - Notifications Composer " diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index 8ff4caaacf..5fba1b1ce5 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -10,6 +10,7 @@ import { } from '../../test-utils/render.js'; import { AppHeader } from './AppHeader.js'; import { describe, it, expect, vi } from 'vitest'; +import { makeFakeConfig } from '@google/gemini-cli-core'; import crypto from 'node:crypto'; vi.mock('../utils/terminalSetup.js', () => ({ @@ -240,4 +241,27 @@ describe('', () => { expect(session2.lastFrame()).not.toContain('Tips'); session2.unmount(); }); + + it('should render the full logo when logged out', async () => { + const mockConfig = makeFakeConfig(); + vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({ + authType: undefined, + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + , + { + config: mockConfig, + uiState: { + terminalWidth: 120, + }, + }, + ); + await waitUntilReady(); + + // Check for block characters from the logo + expect(lastFrame()).toContain('โ–—โ–ˆโ–€โ–€โ–œโ–™'); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index 0b15f917a6..704b094663 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -19,6 +19,9 @@ import { CliSpinner } from './CliSpinner.js'; import { isAppleTerminal } from '@google/gemini-cli-core'; +import { longAsciiLogoCompactText } from './AsciiArt.js'; +import { getAsciiArtWidth } from '../utils/textUtils.js'; + interface AppHeaderProps { version: string; showDetails?: boolean; @@ -41,6 +44,18 @@ const MAC_TERMINAL_ICON = `โ–โ–œโ–„ โ–—โ–Ÿโ–€ โ–—โ–Ÿโ–€ `; +/** + * The horizontal padding (in columns) required for metadata (version, identity, etc.) + * when rendered alongside the ASCII logo. + */ +const LOGO_METADATA_PADDING = 20; + +/** + * The terminal width below which we switch to a narrow/column layout to prevent + * UI elements from wrapping or overlapping. + */ +const NARROW_TERMINAL_BREAKPOINT = 60; + export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => { const settings = useSettings(); const config = useConfig(); @@ -49,70 +64,90 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => { const { bannerText } = useBanner(bannerData); const { showTips } = useTips(); + const authType = config.getContentGeneratorConfig()?.authType; + const loggedOut = !authType; + const showHeader = !( settings.merged.ui.hideBanner || config.getScreenReader() ); const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON; - if (!showDetails) { - return ( - - {showHeader && ( - - - {ICON} - - - - - Gemini CLI - - v{version} - - + let logoTextArt = ''; + if (loggedOut) { + const widthOfLongLogo = + getAsciiArtWidth(longAsciiLogoCompactText) + LOGO_METADATA_PADDING; + + if (terminalWidth >= widthOfLongLogo) { + logoTextArt = longAsciiLogoCompactText.trim(); + } + } + + // If the terminal is too narrow to fit the icon and metadata (especially long nightly versions) + // side-by-side, we switch to column mode to prevent wrapping. + const isNarrow = terminalWidth < NARROW_TERMINAL_BREAKPOINT; + + const renderLogo = () => ( + + + {ICON} + + {logoTextArt && ( + + {logoTextArt} + + )} + + ); + + const renderMetadata = (isBelow = false) => ( + + {/* Line 1: Gemini CLI vVersion [Updating] */} + + + Gemini CLI + + v{version} + {updateInfo && ( + + + Updating + )} - ); - } + + {showDetails && ( + <> + {/* Line 2: Blank */} + + + {/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */} + {settings.merged.ui.showUserIdentity !== false && ( + + )} + + )} + + ); + + const useColumnLayout = !!logoTextArt || isNarrow; return ( {showHeader && ( - - - {ICON} - - - {/* Line 1: Gemini CLI vVersion [Updating] */} - - - Gemini CLI - - v{version} - {updateInfo && ( - - - Updating - - - )} - - - {/* Line 2: Blank */} - - - {/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */} - {settings.merged.ui.showUserIdentity !== false && ( - - )} - + + {renderLogo()} + {useColumnLayout ? ( + {renderMetadata(true)} + ) : ( + renderMetadata(false) + )} )} diff --git a/packages/cli/src/ui/components/AsciiArt.ts b/packages/cli/src/ui/components/AsciiArt.ts index 79eb522c80..40f0eb8296 100644 --- a/packages/cli/src/ui/components/AsciiArt.ts +++ b/packages/cli/src/ui/components/AsciiArt.ts @@ -16,14 +16,14 @@ export const shortAsciiLogo = ` `; export const longAsciiLogo = ` - โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ -โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ - โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–‘ โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆ โ–‘ โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ - โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ - โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–‘ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ - โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘ โ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ - โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ -โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ + โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ +โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ +โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆ โ–‘ โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ +โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ +โ–‘โ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–‘ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ +โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘ โ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–ˆโ–ˆโ–ˆ + โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ + โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘ `; export const tinyAsciiLogo = ` @@ -36,3 +36,24 @@ export const tinyAsciiLogo = ` โ–ˆโ–ˆโ–ˆโ–‘ โ–‘โ–‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–‘โ–‘โ–‘ โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ `; + +export const shortAsciiLogoCompactText = ` +โ–Ÿโ–›โ–€โ–€โ–ˆโ––โ–œโ–ˆโ–€โ–€โ–œโ–โ–ˆโ–ˆโ–™โ–—โ–ˆโ–ˆโ–›โ–โ–ˆโ–›โ–โ–ˆโ–ˆโ–™ โ–œโ–ˆโ–˜โ–œโ–ˆโ–˜ +โ–โ–ˆ โ–โ–ˆโ–„โ–Œ โ–ˆโ–Œโ–œโ–ˆโ–˜โ–ˆโ–Œ โ–ˆโ–Œ โ–ˆโ–Œโ–œโ–™โ–โ–ˆ โ–โ–ˆ +โ–โ–ˆโ–– โ–œโ–ˆโ–˜โ–โ–ˆ โ–˜โ–— โ–ˆโ–Œ โ–ˆโ–Œ โ–ˆโ–Œ โ–ˆโ–Œ โ–œโ–ˆโ–ˆ โ–โ–ˆ + โ–โ–€โ–€โ–€โ–€ โ–€โ–€โ–€โ–€โ–€โ–โ–€โ–€ โ–โ–€โ–€โ–โ–€โ–€โ–โ–€โ–€ โ–€โ–€โ–˜โ–€โ–€โ–˜ +`; + +export const longAsciiLogoCompactText = ` +โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› +โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ +โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ +`; + +export const tinyAsciiLogoCompactText = ` +โ–Ÿโ–›โ–€โ–€โ–ˆโ–– +โ–โ–ˆ +โ–โ–ˆโ–– โ–œโ–ˆโ–˜ + โ–โ–€โ–€โ–€โ–€ +`; diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 864800a061..3710068285 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -1453,4 +1453,42 @@ describe('AskUserDialog', () => { }); }); }); + + it('shows at least 3 selection options even in small terminal heights', async () => { + const questions: Question[] = [ + { + question: + 'A very long question that would normally take up most of the space and squeeze the list if we did not have a heuristic to prevent it. This line is just to make it longer. And another one. Imagine this is a plan.', + header: 'Test', + type: QuestionType.CHOICE, + options: [ + { label: 'Option 1', description: 'Description 1' }, + { label: 'Option 2', description: 'Description 2' }, + { label: 'Option 3', description: 'Description 3' }, + { label: 'Option 4', description: 'Description 4' }, + ], + multiSelect: false, + }, + ]; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 80 }, + ); + + await waitFor(async () => { + await waitUntilReady(); + const frame = lastFrame(); + // Should show at least 3 options + expect(frame).toContain('1. Option 1'); + expect(frame).toContain('2. Option 2'); + expect(frame).toContain('3. Option 3'); + }); + }); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index b1d23885e6..57faaae87c 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -849,11 +849,19 @@ const ChoiceQuestionView: React.FC = ({ ? Math.max(1, availableHeight - overhead) : undefined; + // Reserve space for at least 3 items if more selectionItems available. + const reservedListHeight = Math.min(selectionItems.length * 2, 6); const questionHeightLimit = listHeight && !isAlternateBuffer ? question.unconstrainedHeight ? Math.max(1, listHeight - selectionItems.length * 2) - : Math.min(15, Math.max(1, listHeight - DIALOG_PADDING)) + : Math.min( + 15, + Math.max( + 1, + listHeight - Math.max(DIALOG_PADDING, reservedListHeight), + ), + ) : undefined; const maxItemsToShow = diff --git a/packages/cli/src/ui/components/GradientRegression.test.tsx b/packages/cli/src/ui/components/GradientRegression.test.tsx index dfdad4f1aa..75ecac6f9a 100644 --- a/packages/cli/src/ui/components/GradientRegression.test.tsx +++ b/packages/cli/src/ui/components/GradientRegression.test.tsx @@ -10,7 +10,7 @@ import * as SessionContext from '../contexts/SessionContext.js'; import { type SessionStatsState } from '../contexts/SessionContext.js'; import { Banner } from './Banner.js'; import { Footer } from './Footer.js'; -import { Header } from './Header.js'; +import { AppHeader } from './AppHeader.js'; import { ModelDialog } from './ModelDialog.js'; import { StatsDisplay } from './StatsDisplay.js'; @@ -71,9 +71,9 @@ useSessionStatsMock.mockReturnValue({ }); describe('Gradient Crash Regression Tests', () => { - it('
should not crash when theme.ui.gradient is empty', async () => { + it(' should not crash when theme.ui.gradient is empty', async () => { const { lastFrame, unmount } = await renderWithProviders( -
, + , { width: 120, }, diff --git a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap index 5394ab83c0..d4dc67bbc6 100644 --- a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap @@ -2,10 +2,13 @@ exports[`AlternateBufferQuittingDisplay > renders with a tool awaiting confirmation > with_confirming_tool 1`] = ` " - โ–โ–œโ–„ Gemini CLI v0.10.0 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v0.10.0 + Tips for getting started: @@ -22,10 +25,13 @@ Action Required (was prompted): exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = ` " - โ–โ–œโ–„ Gemini CLI v0.10.0 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v0.10.0 + Tips for getting started: @@ -50,10 +56,13 @@ Tips for getting started: exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = ` " - โ–โ–œโ–„ Gemini CLI v0.10.0 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v0.10.0 + Tips for getting started: @@ -66,10 +75,13 @@ Tips for getting started: exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = ` " - โ–โ–œโ–„ Gemini CLI v0.10.0 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v0.10.0 + Tips for getting started: @@ -90,10 +102,13 @@ Tips for getting started: exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = ` " - โ–โ–œโ–„ Gemini CLI v0.10.0 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v0.10.0 + Tips for getting started: @@ -110,10 +125,13 @@ Tips for getting started: exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = ` " - โ–โ–œโ–„ Gemini CLI v0.10.0 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v0.10.0 + Tips for getting started: diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap index 4411f766de..ee9ea5f708 100644 --- a/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap @@ -2,10 +2,13 @@ exports[` > should not render the banner when no flags are set 1`] = ` " - โ–โ–œโ–„ Gemini CLI v1.0.0 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v1.0.0 + Tips for getting started: @@ -18,10 +21,13 @@ Tips for getting started: exports[` > should not render the default banner if shown count is 5 or more 1`] = ` " - โ–โ–œโ–„ Gemini CLI v1.0.0 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v1.0.0 + Tips for getting started: @@ -34,10 +40,13 @@ Tips for getting started: exports[` > should render the banner with default text 1`] = ` " - โ–โ–œโ–„ Gemini CLI v1.0.0 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v1.0.0 + โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ This is the default banner โ”‚ @@ -53,10 +62,13 @@ Tips for getting started: exports[` > should render the banner with warning text 1`] = ` " - โ–โ–œโ–„ Gemini CLI v1.0.0 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v1.0.0 + โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ There are capacity issues โ”‚ @@ -69,3 +81,14 @@ Tips for getting started: 4. Be specific for the best results " `; + +exports[` > should render the full logo when logged out 1`] = ` +" + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v1.0.0 +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-default-icon-in-standard-terminals.snap.svg b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-default-icon-in-standard-terminals.snap.svg index 4e9d0e67a5..5c4c6426b7 100644 --- a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-default-icon-in-standard-terminals.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-default-icon-in-standard-terminals.snap.svg @@ -1,30 +1,34 @@ - + - + - โ– - โ–œ - โ–„ - Gemini CLI - v1.0.0 - โ– - โ–œ - โ–„ - โ–— - โ–Ÿ - โ–€ - โ– - โ–€ - Tips for getting started: - 1. Create - GEMINI.md - files to customize your interactions - 2. - /help - for more information - 3. Ask coding questions, edit code or run commands - 4. Be specific for the best results + โ– + โ–œ + โ–„ + โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ– + โ–œ + โ–„ + โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–— + โ–Ÿ + โ–€ + โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ– + โ–€ + โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + Gemini CLI + v1.0.0 + Tips for getting started: + 1. Create + GEMINI.md + files to customize your interactions + 2. + /help + for more information + 3. Ask coding questions, edit code or run commands + 4. Be specific for the best results \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-symmetric-icon-in-Apple-Terminal.snap.svg b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-symmetric-icon-in-Apple-Terminal.snap.svg index fa8373acc7..eaa118754f 100644 --- a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-symmetric-icon-in-Apple-Terminal.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-symmetric-icon-in-Apple-Terminal.snap.svg @@ -1,31 +1,35 @@ - + - + - โ– - โ–œ - โ–„ - Gemini CLI - v1.0.0 - โ– - โ–œ - โ–„ - โ–— - โ–Ÿ - โ–€ - โ–— - โ–Ÿ - โ–€ - Tips for getting started: - 1. Create - GEMINI.md - files to customize your interactions - 2. - /help - for more information - 3. Ask coding questions, edit code or run commands - 4. Be specific for the best results + โ– + โ–œ + โ–„ + โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ– + โ–œ + โ–„ + โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–— + โ–Ÿ + โ–€ + โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–— + โ–Ÿ + โ–€ + โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + Gemini CLI + v1.0.0 + Tips for getting started: + 1. Create + GEMINI.md + files to customize your interactions + 2. + /help + for more information + 3. Ask coding questions, edit code or run commands + 4. Be specific for the best results \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap index 2bb5276ee8..c8c4c53c89 100644 --- a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap @@ -2,10 +2,13 @@ exports[`AppHeader Icon Rendering > renders the default icon in standard terminals 1`] = ` " - โ–โ–œโ–„ Gemini CLI v1.0.0 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v1.0.0 + Tips for getting started: @@ -17,10 +20,13 @@ Tips for getting started: exports[`AppHeader Icon Rendering > renders the symmetric icon in Apple Terminal 1`] = ` " - โ–โ–œโ–„ Gemini CLI v1.0.0 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–—โ–Ÿโ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + + Gemini CLI v1.0.0 + Tips for getting started: diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap index 30caf0fb40..3992cdd60c 100644 --- a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap @@ -11,6 +11,17 @@ Enter to submit ยท Esc to cancel " `; +exports[`AskUserDialog > Choice question placeholder > uses default placeholder when not provided 2`] = ` +"Select your preferred language: + + 1. TypeScript + 2. JavaScript +โ— 3. Enter a custom value + +Enter to submit ยท Esc to cancel +" +`; + exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 1`] = ` "Select your preferred language: @@ -22,6 +33,17 @@ Enter to submit ยท Esc to cancel " `; +exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 2`] = ` +"Select your preferred language: + + 1. TypeScript + 2. JavaScript +โ— 3. Type another language... + +Enter to submit ยท Esc to cancel +" +`; + exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = ` "Choose an option @@ -30,6 +52,22 @@ exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scrol Description 1 2. Option 2 Description 2 + 3. Option 3 + Description 3 +โ–ผ + +Enter to select ยท โ†‘/โ†“ to navigate ยท Esc to cancel +" +`; + +exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 2`] = ` +"Choose an option + +โ–ฒ +โ— 1. Option 1 + Description 1 + 2. Option 2 + Description 2 โ–ผ Enter to select ยท โ†‘/โ†“ to navigate ยท Esc to cancel @@ -75,6 +113,45 @@ Enter to select ยท โ†‘/โ†“ to navigate ยท Esc to cancel " `; +exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 2`] = ` +"Choose an option + +โ— 1. Option 1 + Description 1 + 2. Option 2 + Description 2 + 3. Option 3 + Description 3 + 4. Option 4 + Description 4 + 5. Option 5 + Description 5 + 6. Option 6 + Description 6 + 7. Option 7 + Description 7 + 8. Option 8 + Description 8 + 9. Option 9 + Description 9 + 10. Option 10 + Description 10 + 11. Option 11 + Description 11 + 12. Option 12 + Description 12 + 13. Option 13 + Description 13 + 14. Option 14 + Description 14 + 15. Option 15 + Description 15 + 16. Enter a custom value + +Enter to select ยท โ†‘/โ†“ to navigate ยท Esc to cancel +" +`; + exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = ` "What should we name this component? @@ -217,3 +294,19 @@ exports[`AskUserDialog > verifies "All of the above" visual state with snapshot Enter to select ยท โ†‘/โ†“ to navigate ยท Esc to cancel " `; + +exports[`AskUserDialog > verifies "All of the above" visual state with snapshot 2`] = ` +"Which features? +(Select all that apply) + + 1. [x] TypeScript + 2. [x] ESLint +โ— 3. [x] All of the above + Select all options + 4. [ ] Enter a custom value + Done + Finish selection + +Enter to select ยท โ†‘/โ†“ to navigate ยท Esc to cancel +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap index 28929deee5..83802c78e0 100644 --- a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap @@ -14,24 +14,12 @@ Spinner Initializing... exports[`ConfigInitDisplay > truncates list of waiting servers if too many 1`] = ` " -Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more -" -`; - -exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = ` -" -Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more +Spinner Initializing... " `; exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = ` " -Spinner Connecting to MCP servers... (1/2) - Waiting for: server2 -" -`; - -exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = ` -" -Spinner Connecting to MCP servers... (1/2) - Waiting for: server2 +Spinner Initializing... " `; diff --git a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap index 073c106ceb..9e210e3438 100644 --- a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap @@ -27,6 +27,33 @@ Enter to select ยท โ†‘/โ†“ to navigate ยท Ctrl+X to edit plan ยท Esc to cancel " `; +exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 2`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + + 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically + 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool +โ— 3. Type your feedback... + +Enter to submit ยท Ctrl+X to edit plan ยท Esc to cancel +" +`; + exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = ` "Overview @@ -54,6 +81,33 @@ Enter to select ยท โ†‘/โ†“ to navigate ยท Ctrl+X to edit plan ยท Esc to cancel " `; +exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 2`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + + 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically + 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool +โ— 3. Add tests + +Enter to submit ยท Ctrl+X to edit plan ยท Esc to cancel +" +`; + exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = ` " Error reading plan: File not found " @@ -140,6 +194,33 @@ Enter to select ยท โ†‘/โ†“ to navigate ยท Ctrl+X to edit plan ยท Esc to cancel " `; +exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + + 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically + 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool +โ— 3. Type your feedback... + +Enter to submit ยท Ctrl+X to edit plan ยท Esc to cancel +" +`; + exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = ` "Overview @@ -167,6 +248,33 @@ Enter to select ยท โ†‘/โ†“ to navigate ยท Ctrl+X to edit plan ยท Esc to cancel " `; +exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 2`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + + 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically + 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool +โ— 3. Add tests + +Enter to submit ยท Ctrl+X to edit plan ยท Esc to cancel +" +`; + exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = ` " Error reading plan: File not found " diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index 5a2819702e..f40887b3b9 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -78,6 +78,27 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub " `; +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = ` +"โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€ + > [Pasted Text: 10 lines] +โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„ +" +`; + +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = ` +"โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€ + > [Pasted Text: 10 lines] +โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„ +" +`; + +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = ` +"โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€ + > [Pasted Text: 10 lines] +โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„โ–„ +" +`; + exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` "โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€โ–€ > Type your message or @path/to/file diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx index 0501667d1f..b873de80d9 100644 --- a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx @@ -447,6 +447,28 @@ describe('BaseSelectionList', () => { unmount(); }); + it('should correctly calculate scroll offset during the initial render phase', async () => { + // Verify that the component correctly calculates the scroll offset during the + // initial render pass when starting with a high activeIndex. + // List length 10, max items 3, activeIndex 9 (last item). + const { unmount } = await renderScrollableList(9); + + const renderedItemValues = mockRenderItem.mock.calls.map( + (call) => call[0].value, + ); + + // Item 1 (index 0) should not be rendered if the scroll offset is correctly + // synchronized with the activeIndex from the start. + expect(renderedItemValues).not.toContain('Item 1'); + + // The items at the end of the list should be rendered. + expect(renderedItemValues).toContain('Item 8'); + expect(renderedItemValues).toContain('Item 9'); + expect(renderedItemValues).toContain('Item 10'); + + unmount(); + }); + it('should handle maxItemsToShow larger than the list length', async () => { const { lastFrame, unmount } = await renderComponent( { items: longList, maxItemsToShow: 15 }, diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx index 1090d4010d..455069f03f 100644 --- a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Text, Box } from 'ink'; import { theme } from '../../semantic-colors.js'; import { @@ -84,20 +84,27 @@ export function BaseSelectionList< const [scrollOffset, setScrollOffset] = useState(0); - // Handle scrolling for long lists - useEffect(() => { - const newScrollOffset = Math.max( + // Derive the effective scroll offset during render to avoid "no-selection" flicker. + // This ensures that the visibleItems calculation uses an offset that includes activeIndex. + let effectiveScrollOffset = scrollOffset; + if (activeIndex < effectiveScrollOffset) { + effectiveScrollOffset = activeIndex; + } else if (activeIndex >= effectiveScrollOffset + maxItemsToShow) { + effectiveScrollOffset = Math.max( 0, Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow), ); - if (activeIndex < scrollOffset) { - setScrollOffset(activeIndex); - } else if (activeIndex >= scrollOffset + maxItemsToShow) { - setScrollOffset(newScrollOffset); - } - }, [activeIndex, items.length, scrollOffset, maxItemsToShow]); + } - const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow); + // Synchronize state if it changed during derivation + if (effectiveScrollOffset !== scrollOffset) { + setScrollOffset(effectiveScrollOffset); + } + + const visibleItems = items.slice( + effectiveScrollOffset, + effectiveScrollOffset + maxItemsToShow, + ); const numberColumnWidth = String(items.length).length; return ( @@ -105,14 +112,18 @@ export function BaseSelectionList< {/* Use conditional coloring instead of conditional rendering */} {showScrollArrows && items.length > maxItemsToShow && ( 0 ? theme.text.primary : theme.text.secondary} + color={ + effectiveScrollOffset > 0 + ? theme.text.primary + : theme.text.secondary + } > โ–ฒ )} {visibleItems.map((item, index) => { - const itemIndex = scrollOffset + index; + const itemIndex = effectiveScrollOffset + index; const isSelected = activeIndex === itemIndex; // Determine colors based on selection and disabled state @@ -182,7 +193,7 @@ export function BaseSelectionList< {showScrollArrows && items.length > maxItemsToShow && ( + - + - โ– - โ–œ - โ–„ - Gemini CLI - v1.2.3 - โ– - โ–œ - โ–„ - โ–— - โ–Ÿ - โ–€ - โ– - โ–€ - โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ - โ”‚ - โŠถ - google_web_search - โ”‚ - โ”‚ - โ”‚ - โ”‚ - Searching... - โ”‚ - โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + โ– + โ–œ + โ–„ + โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ– + โ–œ + โ–„ + โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–— + โ–Ÿ + โ–€ + โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ– + โ–€ + โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + Gemini CLI + v1.2.3 + Tips for getting started: + 1. Create + GEMINI.md + files to customize your interactions + 2. + /help + for more information + 3. Ask coding questions, edit code or run commands + 4. Be specific for the best results + โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ + โ”‚ + โŠถ + google_web_search + โ”‚ + โ”‚ + โ”‚ + โ”‚ + Searching... + โ”‚ + โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg index 1c0ff4b121..85a715cc01 100644 --- a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg @@ -1,32 +1,45 @@ - + - + - โ– - โ–œ - โ–„ - Gemini CLI - v1.2.3 - โ– - โ–œ - โ–„ - โ–— - โ–Ÿ - โ–€ - โ– - โ–€ - โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ - โ”‚ - โŠถ - run_shell_command - โ”‚ - โ”‚ - โ”‚ - โ”‚ - Running command... - โ”‚ - โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + โ– + โ–œ + โ–„ + โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ– + โ–œ + โ–„ + โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–— + โ–Ÿ + โ–€ + โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ– + โ–€ + โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + Gemini CLI + v1.2.3 + Tips for getting started: + 1. Create + GEMINI.md + files to customize your interactions + 2. + /help + for more information + 3. Ask coding questions, edit code or run commands + 4. Be specific for the best results + โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ + โ”‚ + โŠถ + run_shell_command + โ”‚ + โ”‚ + โ”‚ + โ”‚ + Running command... + โ”‚ + โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg index 6a693d318b..beaa216162 100644 --- a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg @@ -1,32 +1,45 @@ - + - + - โ– - โ–œ - โ–„ - Gemini CLI - v1.2.3 - โ– - โ–œ - โ–„ - โ–— - โ–Ÿ - โ–€ - โ– - โ–€ - โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ - โ”‚ - โŠถ - google_web_search - โ”‚ - โ”‚ - โ”‚ - โ”‚ - Searching... - โ”‚ - โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + โ– + โ–œ + โ–„ + โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ– + โ–œ + โ–„ + โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–— + โ–Ÿ + โ–€ + โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ– + โ–€ + โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + Gemini CLI + v1.2.3 + Tips for getting started: + 1. Create + GEMINI.md + files to customize your interactions + 2. + /help + for more information + 3. Ask coding questions, edit code or run commands + 4. Be specific for the best results + โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ + โ”‚ + โŠถ + google_web_search + โ”‚ + โ”‚ + โ”‚ + โ”‚ + Searching... + โ”‚ + โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap b/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap index bdf1e95332..84baf2edb8 100644 --- a/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap +++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap @@ -2,11 +2,19 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a pending search dialog (google_web_search) 1`] = ` " - โ–โ–œโ–„ Gemini CLI v1.2.3 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + Gemini CLI v1.2.3 + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ โŠถ google_web_search โ”‚ โ”‚ โ”‚ @@ -16,11 +24,19 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a shell tool 1`] = ` " - โ–โ–œโ–„ Gemini CLI v1.2.3 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + Gemini CLI v1.2.3 + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ โŠถ run_shell_command โ”‚ โ”‚ โ”‚ @@ -30,11 +46,19 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for an empty slice following a search tool 1`] = ` " - โ–โ–œโ–„ Gemini CLI v1.2.3 - โ–โ–œโ–„ - โ–—โ–Ÿโ–€ - โ–โ–€ + โ–โ–œโ–„ โ–—โ–ˆโ–€โ–€โ–œโ–™โ–โ–ˆโ–›โ–€โ–€โ–Œโ–œโ–ˆโ–ˆโ––โ–Ÿโ–ˆโ–ˆโ–˜โ–œโ–ˆโ–˜โ–œโ–ˆโ–ˆโ––โ–โ–ˆโ–›โ–โ–ˆโ–› + โ–โ–œโ–„ โ–ˆโ–Œ โ–ˆโ–™โ–Ÿ โ–โ–ˆโ–โ–ˆโ–›โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–โ–ˆโ––โ–ˆโ–Œ โ–ˆโ–Œ + โ–—โ–Ÿโ–€ โ–œโ–™ โ–โ–ˆโ–› โ–ˆโ–Œโ– โ––โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆ โ–โ–ˆโ–ˆโ–Œ โ–ˆโ–Œ + โ–โ–€ โ–€โ–€โ–€โ–€โ–˜โ–โ–€โ–€โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–€โ–€โ–˜โ–€โ–€โ–˜โ–€โ–€โ–˜ โ–โ–€โ–€โ–โ–€โ–€ + Gemini CLI v1.2.3 + + +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ โŠถ google_web_search โ”‚ โ”‚ โ”‚ diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.ts index 7867f48e6f..6aeda005dc 100644 --- a/packages/cli/src/ui/utils/terminalCapabilityManager.ts +++ b/packages/cli/src/ui/utils/terminalCapabilityManager.ts @@ -13,12 +13,14 @@ import { disableModifyOtherKeys, enableBracketedPasteMode, disableBracketedPasteMode, + disableMouseEvents, } from '@google/gemini-cli-core'; import { parseColor } from '../themes/color-utils.js'; export type TerminalBackgroundColor = string | undefined; -const TERMINAL_CLEANUP_SEQUENCE = '\x1b[4;0m\x1b[?2004l'; +const TERMINAL_CLEANUP_SEQUENCE = + '\x1b[4;0m\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l'; export function cleanupTerminalOnExit() { try { @@ -33,6 +35,7 @@ export function cleanupTerminalOnExit() { disableKittyKeyboardProtocol(); disableModifyOtherKeys(); disableBracketedPasteMode(); + disableMouseEvents(); } export class TerminalCapabilityManager { diff --git a/packages/cli/src/ui/utils/terminalSetup.ts b/packages/cli/src/ui/utils/terminalSetup.ts index aaa8d9fc6f..d04dedb4ff 100644 --- a/packages/cli/src/ui/utils/terminalSetup.ts +++ b/packages/cli/src/ui/utils/terminalSetup.ts @@ -502,7 +502,6 @@ export function useTerminalSetupPrompt({ if (hasBeenPrompted) { return; } - let cancelled = false; // eslint-disable-next-line @typescript-eslint/no-floating-promises diff --git a/packages/cli/src/utils/agentSettings.ts b/packages/cli/src/utils/agentSettings.ts index 661b065d18..1ea9054c9c 100644 --- a/packages/cli/src/utils/agentSettings.ts +++ b/packages/cli/src/utils/agentSettings.ts @@ -40,8 +40,8 @@ const agentStrategy: FeatureToggleStrategy = { }; /** - * Enables an agent by ensuring it is enabled in any writable scope (User and Workspace). - * It sets `agents.overrides..enabled` to `true`. + * Enables an agent by setting `agents.overrides..enabled` to `true` + * in available writable scopes (User and Workspace). */ export function enableAgent( settings: LoadedSettings, @@ -59,7 +59,8 @@ export function enableAgent( } /** - * Disables an agent by setting `agents.overrides..enabled` to `false` in the specified scope. + * Disables an agent by setting `agents.overrides..enabled` to `false` + * in the specified scope. */ export function disableAgent( settings: LoadedSettings, diff --git a/packages/cli/src/utils/cleanup.test.ts b/packages/cli/src/utils/cleanup.test.ts index a722e1a737..0e2454cb82 100644 --- a/packages/cli/src/utils/cleanup.test.ts +++ b/packages/cli/src/utils/cleanup.test.ts @@ -72,6 +72,46 @@ describe('cleanup', () => { expect(asyncFn).toHaveBeenCalledTimes(1); }); + it('should run cleanupFunctions BEFORE draining stdin and BEFORE runSyncCleanup', async () => { + const callOrder: string[] = []; + + // Cleanup function + registerCleanup(() => { + callOrder.push('cleanup'); + }); + + // Sync cleanup function (e.g. setRawMode(false)) + registerSyncCleanup(() => { + callOrder.push('sync'); + }); + + // Mock stdin.resume to track drainStdin + const originalResume = process.stdin.resume; + process.stdin.resume = vi.fn().mockImplementation(() => { + callOrder.push('drain'); + return process.stdin; + }); + + // Mock stdin properties for drainStdin + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + + try { + await runExitCleanup(); + } finally { + process.stdin.resume = originalResume; + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + } + + expect(callOrder).toEqual(['drain', 'drain', 'sync', 'cleanup']); + }); + it('should continue running cleanup functions even if one throws an error', async () => { const errorFn = vi.fn().mockImplementation(() => { throw new Error('test error'); diff --git a/packages/cli/src/utils/cleanup.ts b/packages/cli/src/utils/cleanup.ts index 6185b34fe5..19aa795640 100644 --- a/packages/cli/src/utils/cleanup.ts +++ b/packages/cli/src/utils/cleanup.ts @@ -59,7 +59,7 @@ export function registerTelemetryConfig(config: Config) { export async function runExitCleanup() { // drain stdin to prevent printing garbage on exit - // https://github.com/google-gemini/gemini-cli/issues/1680 + // https://github.com/google-gemini/gemini-cli/issues/16801 await drainStdin(); runSyncCleanup(); diff --git a/packages/cli/test-setup.ts b/packages/cli/test-setup.ts index 8d055bc63d..452493559a 100644 --- a/packages/cli/test-setup.ts +++ b/packages/cli/test-setup.ts @@ -30,6 +30,9 @@ process.env.FORCE_COLOR = '3'; // Force generic keybinding hints to ensure stable snapshots across different operating systems. process.env.FORCE_GENERIC_KEYBINDING_HINTS = 'true'; +// Force generic terminal declaration to ensure stable snapshots across different host environments. +process.env.TERM_PROGRAM = 'generic'; + import './src/test-utils/customMatchers.js'; let consoleErrorSpy: vi.SpyInstance; diff --git a/packages/core/src/agent/agent-session.test.ts b/packages/core/src/agent/agent-session.test.ts index c390d719d4..e3ff1c5dc0 100644 --- a/packages/core/src/agent/agent-session.test.ts +++ b/packages/core/src/agent/agent-session.test.ts @@ -32,9 +32,7 @@ describe('AgentSession', () => { await session.abort(); expect( session.events.some( - (e) => - e.type === 'agent_end' && - (e as AgentEvent<'agent_end'>).reason === 'aborted', + (e) => e.type === 'agent_end' && e.reason === 'aborted', ), ).toBe(true); }); @@ -119,6 +117,7 @@ describe('AgentSession', () => { expect(events).toHaveLength(0); expect(protocol.events).toHaveLength(1); expect(protocol.events[0].type).toBe('session_update'); + expect(protocol.events[0].streamId).toEqual(expect.any(String)); }); it('should skip events that occur before agent_start', async () => { @@ -173,6 +172,181 @@ describe('AgentSession', () => { expect(streamedEvents).toEqual(allEvents.slice(2)); }); + it('should complete immediately when resuming from agent_end', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([{ type: 'message' }]); + const { streamId } = await session.send({ + message: [{ type: 'text', text: 'request' }], + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const endEvent = session.events.findLast( + (event): event is AgentEvent<'agent_end'> => + event.type === 'agent_end' && event.streamId === streamId, + ); + expect(endEvent).toBeDefined(); + + const iterator = session + .stream({ eventId: endEvent!.id }) + [Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toEqual({ + value: undefined, + done: true, + }); + }); + + it('should throw for an unknown eventId', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + const iterator = session + .stream({ eventId: 'missing-event' }) + [Symbol.asyncIterator](); + await expect(iterator.next()).rejects.toThrow( + 'Unknown eventId: missing-event', + ); + }); + + it('should throw when resuming from an event before agent_start on a stream with no agent activity', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + const { streamId } = await session.send({ update: { title: 'draft' } }); + expect(streamId).toBeNull(); + + const updateEvent = session.events.find( + (event): event is AgentEvent<'session_update'> => + event.type === 'session_update', + ); + expect(updateEvent).toBeDefined(); + + const iterator = session + .stream({ eventId: updateEvent!.id }) + [Symbol.asyncIterator](); + await expect(iterator.next()).rejects.toThrow( + `Cannot resume from eventId ${updateEvent!.id} before agent_start for stream ${updateEvent!.streamId}`, + ); + }); + + it('should replay from agent_start when resuming from a pre-agent_start event after activity is in history', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([ + { + type: 'message', + role: 'agent', + content: [{ type: 'text', text: 'hello' }], + }, + ]); + await session.send({ + message: [{ type: 'text', text: 'request' }], + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const userMessage = session.events.find( + (event): event is AgentEvent<'message'> => + event.type === 'message' && event.role === 'user', + ); + expect(userMessage).toBeDefined(); + + const streamedEvents: AgentEvent[] = []; + for await (const event of session.stream({ eventId: userMessage!.id })) { + streamedEvents.push(event); + } + + expect(streamedEvents.map((event) => event.type)).toEqual([ + 'agent_start', + 'message', + 'agent_end', + ]); + expect(streamedEvents[0]?.streamId).toBe(userMessage!.streamId); + }); + + it('should throw when resuming from a pre-agent_start event before activity is in history', async () => { + const protocol = new MockAgentProtocol([ + { + id: 'e-1', + timestamp: '2026-01-01T00:00:00.000Z', + streamId: 'stream-1', + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'request' }], + }, + ]); + const session = new AgentSession(protocol); + + const iterator = session + .stream({ eventId: 'e-1' }) + [Symbol.asyncIterator](); + await expect(iterator.next()).rejects.toThrow( + 'Cannot resume from eventId e-1 before agent_start for stream stream-1', + ); + }); + + it('should resume from an in-stream event within the same stream only', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([ + { + type: 'message', + role: 'agent', + content: [{ type: 'text', text: 'first answer 1' }], + }, + { + type: 'message', + role: 'agent', + content: [{ type: 'text', text: 'first answer 2' }], + }, + ]); + const { streamId: streamId1 } = await session.send({ + message: [{ type: 'text', text: 'first request' }], + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + protocol.pushResponse([ + { + type: 'message', + role: 'agent', + content: [{ type: 'text', text: 'second answer' }], + }, + ]); + await session.send({ + message: [{ type: 'text', text: 'second request' }], + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const resumeEvent = session.events.find( + (event): event is AgentEvent<'message'> => + event.type === 'message' && + event.streamId === streamId1 && + event.role === 'agent' && + event.content[0]?.type === 'text' && + event.content[0].text === 'first answer 1', + ); + expect(resumeEvent).toBeDefined(); + + const streamedEvents: AgentEvent[] = []; + for await (const event of session.stream({ eventId: resumeEvent!.id })) { + streamedEvents.push(event); + } + + expect( + streamedEvents.every((event) => event.streamId === streamId1), + ).toBe(true); + expect(streamedEvents.map((event) => event.type)).toEqual([ + 'message', + 'agent_end', + ]); + const resumedMessage = streamedEvents[0] as AgentEvent<'message'>; + expect(resumedMessage.content).toEqual([ + { type: 'text', text: 'first answer 2' }, + ]); + }); + it('should replay events for streamId starting with agent_start', async () => { const protocol = new MockAgentProtocol(); const session = new AgentSession(protocol); @@ -225,6 +399,33 @@ describe('AgentSession', () => { expect(streamedEvents.at(-1)?.type).toBe('agent_end'); }); + it('should not drop agent_end that arrives while replay events are being yielded', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([{ type: 'message' }], { keepOpen: true }); + const { streamId } = await session.send({ update: { title: 't1' } }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const iterator = session + .stream({ streamId: streamId! }) + [Symbol.asyncIterator](); + + const first = await iterator.next(); + expect(first.value?.type).toBe('agent_start'); + + protocol.pushToStream(streamId!, [], { close: true }); + + const second = await iterator.next(); + expect(second.value?.type).toBe('message'); + + const third = await iterator.next(); + expect(third.value?.type).toBe('agent_end'); + + const fourth = await iterator.next(); + expect(fourth.done).toBe(true); + }); + it('should follow an active stream if no options provided', async () => { const protocol = new MockAgentProtocol(); const session = new AgentSession(protocol); diff --git a/packages/core/src/agent/agent-session.ts b/packages/core/src/agent/agent-session.ts index 0d9fc86bb0..6a4c295fc8 100644 --- a/packages/core/src/agent/agent-session.ts +++ b/packages/core/src/agent/agent-session.ts @@ -34,7 +34,7 @@ export class AgentSession implements AgentProtocol { return this._protocol.abort(); } - get events(): AgentEvent[] { + get events(): readonly AgentEvent[] { return this._protocol.events; } @@ -77,6 +77,30 @@ export class AgentSession implements AgentProtocol { let done = false; let trackedStreamId = options.streamId; let started = false; + let agentActivityStarted = false; + + const queueVisibleEvent = (event: AgentEvent): void => { + if (trackedStreamId && event.streamId !== trackedStreamId) { + return; + } + + if (!agentActivityStarted) { + if (event.type !== 'agent_start') { + return; + } + trackedStreamId = event.streamId; + agentActivityStarted = true; + } + + if (!trackedStreamId) { + return; + } + + eventQueue.push(event); + if (event.type === 'agent_end' && event.streamId === trackedStreamId) { + done = true; + } + }; // 1. Subscribe early to avoid missing any events that occur during replay setup const unsubscribe = this._protocol.subscribe((event) => { @@ -87,23 +111,7 @@ export class AgentSession implements AgentProtocol { return; } - if (trackedStreamId && event.streamId !== trackedStreamId) return; - - // If we don't have a tracked stream yet, the first agent_start we see becomes it. - if (!trackedStreamId && event.type === 'agent_start') { - trackedStreamId = event.streamId ?? undefined; - } - - // If we still don't have a tracked stream and we aren't replaying everything (eventId), ignore. - if (!trackedStreamId && !options.eventId) return; - - eventQueue.push(event); - if ( - event.type === 'agent_end' && - event.streamId === (trackedStreamId ?? null) - ) { - done = true; - } + queueVisibleEvent(event); const currentResolve = resolve; next = new Promise((r) => { @@ -118,8 +126,42 @@ export class AgentSession implements AgentProtocol { if (options.eventId) { const index = currentEvents.findIndex((e) => e.id === options.eventId); - if (index !== -1) { + if (index === -1) { + throw new Error(`Unknown eventId: ${options.eventId}`); + } + + const resumeEvent = currentEvents[index]; + trackedStreamId = resumeEvent.streamId; + const firstAgentStartIndex = currentEvents.findIndex( + (event) => + event.type === 'agent_start' && event.streamId === trackedStreamId, + ); + + if (resumeEvent.type === 'agent_end') { replayStartIndex = index + 1; + agentActivityStarted = true; + done = true; + } else if ( + firstAgentStartIndex !== -1 && + firstAgentStartIndex <= index + ) { + replayStartIndex = index + 1; + agentActivityStarted = true; + } else if (firstAgentStartIndex !== -1) { + // A pre-agent_start cursor can be resumed once the corresponding + // agent activity is already present in history. Because stream() + // yields only agent_start -> agent_end, replay begins at agent_start + // rather than at the original pre-start event. + replayStartIndex = firstAgentStartIndex; + } else { + // Consumers can only resume by eventId once the corresponding stream + // has entered the agent_start -> agent_end lifecycle in history. + // Without a recorded agent_start, this wrapper cannot distinguish + // "agent activity may start later" from "this send was acknowledged + // without agent activity" without risking an infinite wait. + throw new Error( + `Cannot resume from eventId ${options.eventId} before agent_start for stream ${trackedStreamId}`, + ); } } else if (options.streamId) { const index = currentEvents.findIndex( @@ -128,29 +170,7 @@ export class AgentSession implements AgentProtocol { if (index !== -1) { replayStartIndex = index; } - } - - if (replayStartIndex !== -1) { - for (let i = replayStartIndex; i < currentEvents.length; i++) { - const event = currentEvents[i]; - if (options.streamId && event.streamId !== options.streamId) continue; - - eventQueue.push(event); - if (event.type === 'agent_start' && !trackedStreamId) { - trackedStreamId = event.streamId ?? undefined; - } - if ( - event.type === 'agent_end' && - event.streamId === (trackedStreamId ?? null) - ) { - done = true; - break; - } - } - } - - if (!done && !trackedStreamId) { - // Find active stream in history + } else { const activeStarts = currentEvents.filter( (e) => e.type === 'agent_start', ); @@ -161,36 +181,28 @@ export class AgentSession implements AgentProtocol { (e) => e.type === 'agent_end' && e.streamId === start.streamId, ) ) { - trackedStreamId = start.streamId ?? undefined; + trackedStreamId = start.streamId; + replayStartIndex = currentEvents.findIndex( + (e) => e.id === start.id, + ); break; } } } - // If we replayed to the end and no stream is active, and we were specifically - // replaying from an eventId (or we've already finished the stream we were looking for), we are done. - if (!done && !trackedStreamId && options.eventId) { - done = true; + if (replayStartIndex !== -1) { + for (let i = replayStartIndex; i < currentEvents.length; i++) { + const event = currentEvents[i]; + queueVisibleEvent(event); + if (done) break; + } } - started = true; // Process events that arrived while we were replaying for (const event of earlyEvents) { if (done) break; - if (trackedStreamId && event.streamId !== trackedStreamId) continue; - if (!trackedStreamId && event.type === 'agent_start') { - trackedStreamId = event.streamId ?? undefined; - } - if (!trackedStreamId && !options.eventId) continue; - - eventQueue.push(event); - if ( - event.type === 'agent_end' && - event.streamId === (trackedStreamId ?? null) - ) { - done = true; - } + queueVisibleEvent(event); } while (true) { @@ -200,6 +212,7 @@ export class AgentSession implements AgentProtocol { for (const event of eventsToYield) { yield event; } + continue; } if (done) break; diff --git a/packages/core/src/agent/event-translator.test.ts b/packages/core/src/agent/event-translator.test.ts new file mode 100644 index 0000000000..f40c6c27ad --- /dev/null +++ b/packages/core/src/agent/event-translator.test.ts @@ -0,0 +1,733 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, beforeEach } from 'vitest'; +import { FinishReason } from '@google/genai'; +import { ToolErrorType } from '../tools/tool-error.js'; +import { + translateEvent, + createTranslationState, + mapFinishReason, + mapHttpToGrpcStatus, + mapError, + mapUsage, + type TranslationState, +} from './event-translator.js'; +import { GeminiEventType } from '../core/turn.js'; +import type { ServerGeminiStreamEvent } from '../core/turn.js'; +import type { AgentEvent } from './types.js'; + +describe('createTranslationState', () => { + it('creates state with default streamId', () => { + const state = createTranslationState(); + expect(state.streamId).toBeDefined(); + expect(state.streamStartEmitted).toBe(false); + expect(state.model).toBeUndefined(); + expect(state.eventCounter).toBe(0); + expect(state.pendingToolNames.size).toBe(0); + }); + + it('creates state with custom streamId', () => { + const state = createTranslationState('custom-stream'); + expect(state.streamId).toBe('custom-stream'); + }); +}); + +describe('translateEvent', () => { + let state: TranslationState; + + beforeEach(() => { + state = createTranslationState('test-stream'); + }); + + describe('Content events', () => { + it('emits agent_start + message for first content event', () => { + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Content, + value: 'Hello world', + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(2); + expect(result[0]?.type).toBe('agent_start'); + expect(result[1]?.type).toBe('message'); + const msg = result[1] as AgentEvent<'message'>; + expect(msg.role).toBe('agent'); + expect(msg.content).toEqual([{ type: 'text', text: 'Hello world' }]); + }); + + it('skips agent_start for subsequent content events', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Content, + value: 'more text', + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe('message'); + }); + }); + + describe('Thought events', () => { + it('emits thought content with metadata', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Thought, + value: { subject: 'Planning', description: 'I am thinking...' }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const msg = result[0] as AgentEvent<'message'>; + expect(msg.content).toEqual([ + { type: 'thought', thought: 'I am thinking...' }, + ]); + expect(msg._meta?.['subject']).toBe('Planning'); + }); + }); + + describe('ToolCallRequest events', () => { + it('emits tool_request and tracks pending tool name', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'call-1', + name: 'read_file', + args: { path: '/tmp/test' }, + isClientInitiated: false, + prompt_id: 'p1', + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const req = result[0] as AgentEvent<'tool_request'>; + expect(req.requestId).toBe('call-1'); + expect(req.name).toBe('read_file'); + expect(req.args).toEqual({ path: '/tmp/test' }); + expect(state.pendingToolNames.get('call-1')).toBe('read_file'); + }); + }); + + describe('ToolCallResponse events', () => { + it('emits tool_response with content from responseParts', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-1', 'read_file'); + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-1', + responseParts: [{ text: 'file contents' }], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.requestId).toBe('call-1'); + expect(resp.name).toBe('read_file'); + expect(resp.content).toEqual([{ type: 'text', text: 'file contents' }]); + expect(resp.isError).toBe(false); + expect(state.pendingToolNames.has('call-1')).toBe(false); + }); + + it('uses error.message for content when tool errored', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-2', 'write_file'); + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-2', + responseParts: [{ text: 'stale parts' }], + resultDisplay: 'Permission denied', + error: new Error('Permission denied to write'), + errorType: ToolErrorType.PERMISSION_DENIED, + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.isError).toBe(true); + // Should use error.message, not responseParts + expect(resp.content).toEqual([ + { type: 'text', text: 'Permission denied to write' }, + ]); + expect(resp.displayContent).toEqual([ + { type: 'text', text: 'Permission denied' }, + ]); + expect(resp.data).toEqual({ errorType: 'permission_denied' }); + }); + + it('uses "unknown" name for untracked tool calls', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'untracked', + responseParts: [{ text: 'data' }], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + }, + }; + const result = translateEvent(event, state); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.name).toBe('unknown'); + }); + + it('stringifies object resultDisplay correctly', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-3', 'diff_tool'); + const objectDisplay = { + fileDiff: '@@ -1 +1 @@\n-a\n+b', + fileName: 'test.txt', + filePath: '/tmp/test.txt', + originalContent: 'a', + newContent: 'b', + }; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-3', + responseParts: [{ text: 'diff result' }], + resultDisplay: objectDisplay, + error: undefined, + errorType: undefined, + }, + }; + const result = translateEvent(event, state); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.displayContent).toEqual([ + { type: 'text', text: JSON.stringify(objectDisplay) }, + ]); + }); + + it('passes through string resultDisplay as-is', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-4', 'shell'); + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-4', + responseParts: [{ text: 'output' }], + resultDisplay: 'Command output text', + error: undefined, + errorType: undefined, + }, + }; + const result = translateEvent(event, state); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.displayContent).toEqual([ + { type: 'text', text: 'Command output text' }, + ]); + }); + + it('preserves outputFile and contentLength in data', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-5', 'write_file'); + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-5', + responseParts: [{ text: 'written' }], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + outputFile: '/tmp/out.txt', + contentLength: 42, + }, + }; + const result = translateEvent(event, state); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.data?.['outputFile']).toBe('/tmp/out.txt'); + expect(resp.data?.['contentLength']).toBe(42); + }); + + it('handles multi-part responses (text + inlineData)', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-6', 'screenshot'); + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-6', + responseParts: [ + { text: 'Here is the screenshot' }, + { inlineData: { data: 'base64img', mimeType: 'image/png' } }, + ], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + }, + }; + const result = translateEvent(event, state); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.content).toEqual([ + { type: 'text', text: 'Here is the screenshot' }, + { type: 'media', data: 'base64img', mimeType: 'image/png' }, + ]); + expect(resp.isError).toBe(false); + }); + }); + + describe('Error events', () => { + it('emits error event for structured errors', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Error, + value: { error: { message: 'Rate limited', status: 429 } }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const err = result[0] as AgentEvent<'error'>; + expect(err.status).toBe('RESOURCE_EXHAUSTED'); + expect(err.message).toBe('Rate limited'); + expect(err.fatal).toBe(true); + }); + + it('emits error event for Error instances', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Error, + value: { error: new Error('Something broke') }, + }; + const result = translateEvent(event, state); + const err = result[0] as AgentEvent<'error'>; + expect(err.status).toBe('INTERNAL'); + expect(err.message).toBe('Something broke'); + }); + }); + + describe('ModelInfo events', () => { + it('emits agent_start and session_update when no stream started yet', () => { + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ModelInfo, + value: 'gemini-2.5-pro', + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(2); + expect(result[0]?.type).toBe('agent_start'); + expect(result[1]?.type).toBe('session_update'); + const sessionUpdate = result[1] as AgentEvent<'session_update'>; + expect(sessionUpdate.model).toBe('gemini-2.5-pro'); + expect(state.model).toBe('gemini-2.5-pro'); + expect(state.streamStartEmitted).toBe(true); + }); + + it('emits session_update when stream already started', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ModelInfo, + value: 'gemini-2.5-flash', + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe('session_update'); + }); + }); + + describe('AgentExecutionStopped events', () => { + it('emits agent_end with the final stop message in data.message', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.AgentExecutionStopped, + value: { + reason: 'before_model', + systemMessage: 'Stopped by hook', + contextCleared: true, + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const streamEnd = result[0] as AgentEvent<'agent_end'>; + expect(streamEnd.type).toBe('agent_end'); + expect(streamEnd.reason).toBe('completed'); + expect(streamEnd.data).toEqual({ message: 'Stopped by hook' }); + }); + + it('uses reason when systemMessage is not set', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.AgentExecutionStopped, + value: { reason: 'hook' }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const streamEnd = result[0] as AgentEvent<'agent_end'>; + expect(streamEnd.data).toEqual({ message: 'hook' }); + }); + }); + + describe('AgentExecutionBlocked events', () => { + it('emits non-fatal error event (non-terminal, stream continues)', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.AgentExecutionBlocked, + value: { reason: 'Policy violation' }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const err = result[0] as AgentEvent<'error'>; + expect(err.type).toBe('error'); + expect(err.fatal).toBe(false); + expect(err._meta?.['code']).toBe('AGENT_EXECUTION_BLOCKED'); + expect(err.message).toBe('Agent execution blocked: Policy violation'); + }); + + it('uses systemMessage in the final error message when available', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.AgentExecutionBlocked, + value: { + reason: 'hook_blocked', + systemMessage: 'Blocked by policy hook', + contextCleared: true, + }, + }; + const result = translateEvent(event, state); + const err = result[0] as AgentEvent<'error'>; + expect(err.message).toBe( + 'Agent execution blocked: Blocked by policy hook', + ); + }); + }); + + describe('LoopDetected events', () => { + it('emits a non-fatal warning error event', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.LoopDetected, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe('error'); + const loopWarning = result[0] as AgentEvent<'error'>; + expect(loopWarning.fatal).toBe(false); + expect(loopWarning.message).toBe('Loop detected, stopping execution'); + expect(loopWarning._meta?.['code']).toBe('LOOP_DETECTED'); + }); + }); + + describe('MaxSessionTurns events', () => { + it('emits agent_end with max_turns', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.MaxSessionTurns, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const streamEnd = result[0] as AgentEvent<'agent_end'>; + expect(streamEnd.type).toBe('agent_end'); + expect(streamEnd.reason).toBe('max_turns'); + expect(streamEnd.data).toEqual({ code: 'MAX_TURNS_EXCEEDED' }); + }); + }); + + describe('Finished events', () => { + it('emits usage for STOP', () => { + state.streamStartEmitted = true; + state.model = 'gemini-2.5-pro'; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Finished, + value: { + reason: FinishReason.STOP, + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 50, + cachedContentTokenCount: 10, + }, + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + + const usage = result[0] as AgentEvent<'usage'>; + expect(usage.model).toBe('gemini-2.5-pro'); + expect(usage.inputTokens).toBe(100); + expect(usage.outputTokens).toBe(50); + expect(usage.cachedTokens).toBe(10); + }); + + it('emits nothing when no usage metadata is present', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: undefined }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(0); + }); + }); + + describe('Citation events', () => { + it('emits message with citation meta', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Citation, + value: 'Source: example.com', + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const msg = result[0] as AgentEvent<'message'>; + expect(msg.content).toEqual([ + { type: 'text', text: 'Source: example.com' }, + ]); + expect(msg._meta?.['citation']).toBe(true); + }); + }); + + describe('UserCancelled events', () => { + it('emits agent_end with reason aborted', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.UserCancelled, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const end = result[0] as AgentEvent<'agent_end'>; + expect(end.type).toBe('agent_end'); + expect(end.reason).toBe('aborted'); + }); + }); + + describe('ContextWindowWillOverflow events', () => { + it('emits fatal error', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ContextWindowWillOverflow, + value: { + estimatedRequestTokenCount: 150000, + remainingTokenCount: 10000, + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const err = result[0] as AgentEvent<'error'>; + expect(err.status).toBe('RESOURCE_EXHAUSTED'); + expect(err.fatal).toBe(true); + expect(err.message).toContain('150000'); + expect(err.message).toContain('10000'); + }); + }); + + describe('InvalidStream events', () => { + it('emits fatal error', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.InvalidStream, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const err = result[0] as AgentEvent<'error'>; + expect(err.status).toBe('INTERNAL'); + expect(err.message).toBe('Invalid stream received from model'); + expect(err.fatal).toBe(true); + }); + }); + + describe('Events with no output', () => { + it('returns empty for Retry', () => { + const result = translateEvent({ type: GeminiEventType.Retry }, state); + expect(result).toEqual([]); + }); + + it('returns empty for ChatCompressed with null', () => { + const result = translateEvent( + { type: GeminiEventType.ChatCompressed, value: null }, + state, + ); + expect(result).toEqual([]); + }); + + it('returns empty for ToolCallConfirmation', () => { + // ToolCallConfirmation is skipped in non-interactive mode (elicitations + // are deferred to the interactive runtime adaptation). + const event = { + type: GeminiEventType.ToolCallConfirmation, + value: { + request: { + callId: 'c1', + name: 'tool', + args: {}, + isClientInitiated: false, + prompt_id: 'p1', + }, + details: { type: 'info', title: 'Confirm', prompt: 'Confirm?' }, + }, + } as ServerGeminiStreamEvent; + const result = translateEvent(event, state); + expect(result).toEqual([]); + }); + }); + + describe('Event IDs', () => { + it('generates sequential IDs', () => { + state.streamStartEmitted = true; + const e1 = translateEvent( + { type: GeminiEventType.Content, value: 'a' }, + state, + ); + const e2 = translateEvent( + { type: GeminiEventType.Content, value: 'b' }, + state, + ); + expect(e1[0]?.id).toBe('test-stream-0'); + expect(e2[0]?.id).toBe('test-stream-1'); + }); + + it('includes streamId in events', () => { + const events = translateEvent( + { type: GeminiEventType.Content, value: 'hi' }, + state, + ); + for (const e of events) { + expect(e.streamId).toBe('test-stream'); + } + }); + }); +}); + +describe('mapFinishReason', () => { + it('maps STOP to completed', () => { + expect(mapFinishReason(FinishReason.STOP)).toBe('completed'); + }); + + it('maps undefined to completed', () => { + expect(mapFinishReason(undefined)).toBe('completed'); + }); + + it('maps MAX_TOKENS to max_budget', () => { + expect(mapFinishReason(FinishReason.MAX_TOKENS)).toBe('max_budget'); + }); + + it('maps SAFETY to refusal', () => { + expect(mapFinishReason(FinishReason.SAFETY)).toBe('refusal'); + }); + + it('maps MALFORMED_FUNCTION_CALL to failed', () => { + expect(mapFinishReason(FinishReason.MALFORMED_FUNCTION_CALL)).toBe( + 'failed', + ); + }); + + it('maps RECITATION to refusal', () => { + expect(mapFinishReason(FinishReason.RECITATION)).toBe('refusal'); + }); + + it('maps LANGUAGE to refusal', () => { + expect(mapFinishReason(FinishReason.LANGUAGE)).toBe('refusal'); + }); + + it('maps BLOCKLIST to refusal', () => { + expect(mapFinishReason(FinishReason.BLOCKLIST)).toBe('refusal'); + }); + + it('maps OTHER to failed', () => { + expect(mapFinishReason(FinishReason.OTHER)).toBe('failed'); + }); + + it('maps PROHIBITED_CONTENT to refusal', () => { + expect(mapFinishReason(FinishReason.PROHIBITED_CONTENT)).toBe('refusal'); + }); + + it('maps IMAGE_SAFETY to refusal', () => { + expect(mapFinishReason(FinishReason.IMAGE_SAFETY)).toBe('refusal'); + }); + + it('maps IMAGE_PROHIBITED_CONTENT to refusal', () => { + expect(mapFinishReason(FinishReason.IMAGE_PROHIBITED_CONTENT)).toBe( + 'refusal', + ); + }); + + it('maps UNEXPECTED_TOOL_CALL to failed', () => { + expect(mapFinishReason(FinishReason.UNEXPECTED_TOOL_CALL)).toBe('failed'); + }); + + it('maps NO_IMAGE to failed', () => { + expect(mapFinishReason(FinishReason.NO_IMAGE)).toBe('failed'); + }); +}); + +describe('mapHttpToGrpcStatus', () => { + it('maps 400 to INVALID_ARGUMENT', () => { + expect(mapHttpToGrpcStatus(400)).toBe('INVALID_ARGUMENT'); + }); + + it('maps 401 to UNAUTHENTICATED', () => { + expect(mapHttpToGrpcStatus(401)).toBe('UNAUTHENTICATED'); + }); + + it('maps 429 to RESOURCE_EXHAUSTED', () => { + expect(mapHttpToGrpcStatus(429)).toBe('RESOURCE_EXHAUSTED'); + }); + + it('maps undefined to INTERNAL', () => { + expect(mapHttpToGrpcStatus(undefined)).toBe('INTERNAL'); + }); + + it('maps unknown codes to INTERNAL', () => { + expect(mapHttpToGrpcStatus(418)).toBe('INTERNAL'); + }); +}); + +describe('mapError', () => { + it('maps structured errors with status', () => { + const result = mapError({ message: 'Rate limit', status: 429 }); + expect(result.status).toBe('RESOURCE_EXHAUSTED'); + expect(result.message).toBe('Rate limit'); + expect(result.fatal).toBe(true); + expect(result._meta?.['rawError']).toEqual({ + message: 'Rate limit', + status: 429, + }); + }); + + it('maps Error instances', () => { + const result = mapError(new Error('Something failed')); + expect(result.status).toBe('INTERNAL'); + expect(result.message).toBe('Something failed'); + }); + + it('preserves error name in _meta', () => { + class CustomError extends Error { + constructor(msg: string) { + super(msg); + } + } + const result = mapError(new CustomError('test')); + expect(result._meta?.['errorName']).toBe('CustomError'); + }); + + it('maps non-Error values to string', () => { + const result = mapError('raw string error'); + expect(result.message).toBe('raw string error'); + expect(result.status).toBe('INTERNAL'); + }); +}); + +describe('mapUsage', () => { + it('maps all fields', () => { + const result = mapUsage( + { + promptTokenCount: 100, + candidatesTokenCount: 50, + cachedContentTokenCount: 25, + }, + 'gemini-2.5-pro', + ); + expect(result).toEqual({ + model: 'gemini-2.5-pro', + inputTokens: 100, + outputTokens: 50, + cachedTokens: 25, + }); + }); + + it('uses "unknown" for missing model', () => { + const result = mapUsage({}); + expect(result.model).toBe('unknown'); + }); +}); diff --git a/packages/core/src/agent/event-translator.ts b/packages/core/src/agent/event-translator.ts new file mode 100644 index 0000000000..73f93f4a15 --- /dev/null +++ b/packages/core/src/agent/event-translator.ts @@ -0,0 +1,457 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Pure, stateless-per-call translation functions that convert + * ServerGeminiStreamEvent objects into AgentEvent objects. + * + * No side effects, no generators. Each call to `translateEvent` takes an event + * and mutable TranslationState, returning zero or more AgentEvents. + */ + +import type { FinishReason } from '@google/genai'; +import { GeminiEventType } from '../core/turn.js'; +import type { + ServerGeminiStreamEvent, + StructuredError, + GeminiFinishedEventValue, +} from '../core/turn.js'; +import type { + AgentEvent, + StreamEndReason, + ErrorData, + Usage, + AgentEventType, +} from './types.js'; +import { + geminiPartsToContentParts, + toolResultDisplayToContentParts, + buildToolResponseData, +} from './content-utils.js'; + +// --------------------------------------------------------------------------- +// Translation State +// --------------------------------------------------------------------------- + +export interface TranslationState { + streamId: string; + streamStartEmitted: boolean; + model: string | undefined; + eventCounter: number; + /** Tracks callId โ†’ tool name from requests so responses can reference the name. */ + pendingToolNames: Map; +} + +export function createTranslationState(streamId?: string): TranslationState { + return { + streamId: streamId ?? crypto.randomUUID(), + streamStartEmitted: false, + model: undefined, + eventCounter: 0, + pendingToolNames: new Map(), + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeEvent( + type: T, + state: TranslationState, + payload: Partial>, +): AgentEvent { + const id = `${state.streamId}-${state.eventCounter++}`; + // TypeScript cannot preserve the specific discriminated union member across + // this generic object assembly, so keep the narrowing local to the event + // constructor boundary. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return { + ...payload, + id, + timestamp: new Date().toISOString(), + streamId: state.streamId, + type, + } as AgentEvent; +} + +function ensureStreamStart(state: TranslationState, out: AgentEvent[]): void { + if (!state.streamStartEmitted) { + out.push(makeEvent('agent_start', state, {})); + state.streamStartEmitted = true; + } +} + +// --------------------------------------------------------------------------- +// Core Translator +// --------------------------------------------------------------------------- + +/** + * Translates a single ServerGeminiStreamEvent into zero or more AgentEvents. + * Mutates `state` (counter, flags) as a side effect. + */ +export function translateEvent( + event: ServerGeminiStreamEvent, + state: TranslationState, +): AgentEvent[] { + const out: AgentEvent[] = []; + + switch (event.type) { + case GeminiEventType.ModelInfo: + state.model = event.value; + ensureStreamStart(state, out); + out.push(makeEvent('session_update', state, { model: event.value })); + break; + + case GeminiEventType.Content: + ensureStreamStart(state, out); + out.push( + makeEvent('message', state, { + role: 'agent', + content: [{ type: 'text', text: event.value }], + }), + ); + break; + + case GeminiEventType.Thought: + ensureStreamStart(state, out); + out.push( + makeEvent('message', state, { + role: 'agent', + content: [{ type: 'thought', thought: event.value.description }], + _meta: event.value.subject + ? { source: 'agent', subject: event.value.subject } + : { source: 'agent' }, + }), + ); + break; + + case GeminiEventType.Citation: + ensureStreamStart(state, out); + out.push( + makeEvent('message', state, { + role: 'agent', + content: [{ type: 'text', text: event.value }], + _meta: { source: 'agent', citation: true }, + }), + ); + break; + + case GeminiEventType.Finished: + handleFinished(event.value, state, out); + break; + + case GeminiEventType.Error: + handleError(event.value.error, state, out); + break; + + case GeminiEventType.UserCancelled: + ensureStreamStart(state, out); + out.push( + makeEvent('agent_end', state, { + reason: 'aborted', + }), + ); + break; + + case GeminiEventType.MaxSessionTurns: + ensureStreamStart(state, out); + out.push( + makeEvent('agent_end', state, { + reason: 'max_turns', + data: { + code: 'MAX_TURNS_EXCEEDED', + }, + }), + ); + break; + + case GeminiEventType.LoopDetected: + ensureStreamStart(state, out); + out.push( + makeEvent('error', state, { + status: 'INTERNAL', + message: 'Loop detected, stopping execution', + fatal: false, + _meta: { code: 'LOOP_DETECTED' }, + }), + ); + break; + + case GeminiEventType.ContextWindowWillOverflow: + ensureStreamStart(state, out); + out.push( + makeEvent('error', state, { + status: 'RESOURCE_EXHAUSTED', + message: `Context window will overflow (estimated: ${event.value.estimatedRequestTokenCount}, remaining: ${event.value.remainingTokenCount})`, + fatal: true, + }), + ); + break; + + case GeminiEventType.AgentExecutionStopped: + ensureStreamStart(state, out); + out.push( + makeEvent('agent_end', state, { + reason: 'completed', + data: { + message: event.value.systemMessage?.trim() || event.value.reason, + }, + }), + ); + break; + + case GeminiEventType.AgentExecutionBlocked: + ensureStreamStart(state, out); + out.push( + makeEvent('error', state, { + status: 'PERMISSION_DENIED', + message: `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`, + fatal: false, + _meta: { code: 'AGENT_EXECUTION_BLOCKED' }, + }), + ); + break; + + case GeminiEventType.InvalidStream: + ensureStreamStart(state, out); + out.push( + makeEvent('error', state, { + status: 'INTERNAL', + message: 'Invalid stream received from model', + fatal: true, + }), + ); + break; + + case GeminiEventType.ToolCallRequest: + ensureStreamStart(state, out); + state.pendingToolNames.set(event.value.callId, event.value.name); + out.push( + makeEvent('tool_request', state, { + requestId: event.value.callId, + name: event.value.name, + args: event.value.args, + }), + ); + break; + + case GeminiEventType.ToolCallResponse: { + ensureStreamStart(state, out); + const displayContent = toolResultDisplayToContentParts( + event.value.resultDisplay, + ); + const data = buildToolResponseData(event.value); + out.push( + makeEvent('tool_response', state, { + requestId: event.value.callId, + name: state.pendingToolNames.get(event.value.callId) ?? 'unknown', + content: event.value.error + ? [{ type: 'text', text: event.value.error.message }] + : geminiPartsToContentParts(event.value.responseParts), + isError: event.value.error !== undefined, + ...(displayContent ? { displayContent } : {}), + ...(data ? { data } : {}), + }), + ); + state.pendingToolNames.delete(event.value.callId); + break; + } + + case GeminiEventType.ToolCallConfirmation: + // Elicitations are handled separately by the session layer + break; + + // Internal concerns โ€” no AgentEvent emitted + case GeminiEventType.ChatCompressed: + case GeminiEventType.Retry: + break; + + default: + ((x: never) => { + throw new Error(`Unhandled event type: ${JSON.stringify(x)}`); + })(event); + break; + } + + return out; +} + +// --------------------------------------------------------------------------- +// Finished Event Handling +// --------------------------------------------------------------------------- + +function handleFinished( + value: GeminiFinishedEventValue, + state: TranslationState, + out: AgentEvent[], +): void { + if (value.usageMetadata) { + ensureStreamStart(state, out); + const usage = mapUsage(value.usageMetadata, state.model); + out.push(makeEvent('usage', state, usage)); + } +} + +// --------------------------------------------------------------------------- +// Error Handling +// --------------------------------------------------------------------------- + +function handleError( + error: unknown, + state: TranslationState, + out: AgentEvent[], +): void { + ensureStreamStart(state, out); + + const mapped = mapError(error); + out.push(makeEvent('error', state, mapped)); +} + +// --------------------------------------------------------------------------- +// Public Mapping Functions +// --------------------------------------------------------------------------- + +/** + * Maps a Gemini FinishReason to an AgentEnd reason. + */ +export function mapFinishReason( + reason: FinishReason | undefined, +): StreamEndReason { + if (!reason) return 'completed'; + + switch (reason) { + case 'STOP': + case 'FINISH_REASON_UNSPECIFIED': + return 'completed'; + case 'MAX_TOKENS': + return 'max_budget'; + case 'SAFETY': + case 'RECITATION': + case 'LANGUAGE': + case 'BLOCKLIST': + case 'PROHIBITED_CONTENT': + case 'SPII': + case 'IMAGE_SAFETY': + case 'IMAGE_PROHIBITED_CONTENT': + return 'refusal'; + case 'MALFORMED_FUNCTION_CALL': + case 'OTHER': + case 'UNEXPECTED_TOOL_CALL': + case 'NO_IMAGE': + return 'failed'; + default: + return 'failed'; + } +} + +/** + * Maps an HTTP status code to a gRPC-style status string. + */ +export function mapHttpToGrpcStatus( + httpStatus: number | undefined, +): ErrorData['status'] { + if (httpStatus === undefined) return 'INTERNAL'; + + switch (httpStatus) { + case 400: + return 'INVALID_ARGUMENT'; + case 401: + return 'UNAUTHENTICATED'; + case 403: + return 'PERMISSION_DENIED'; + case 404: + return 'NOT_FOUND'; + case 409: + return 'ALREADY_EXISTS'; + case 429: + return 'RESOURCE_EXHAUSTED'; + case 500: + return 'INTERNAL'; + case 501: + return 'UNIMPLEMENTED'; + case 503: + return 'UNAVAILABLE'; + case 504: + return 'DEADLINE_EXCEEDED'; + default: + return 'INTERNAL'; + } +} + +/** + * Maps a StructuredError (or unknown error value) to an ErrorData payload. + * Preserves selected error metadata in _meta and includes raw structured + * errors for lossless debugging. + */ +export function mapError( + error: unknown, +): ErrorData & { _meta?: Record } { + const meta: Record = {}; + + if (error instanceof Error) { + meta['errorName'] = error.constructor.name; + if ('exitCode' in error && typeof error.exitCode === 'number') { + meta['exitCode'] = error.exitCode; + } + if ('code' in error) { + meta['code'] = error.code; + } + } + + if (isStructuredError(error)) { + const structuredMeta = { ...meta, rawError: error }; + return { + status: mapHttpToGrpcStatus(error.status), + message: error.message, + fatal: true, + _meta: structuredMeta, + }; + } + + if (error instanceof Error) { + return { + status: 'INTERNAL', + message: error.message, + fatal: true, + ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}), + }; + } + + return { + status: 'INTERNAL', + message: String(error), + fatal: true, + }; +} + +function isStructuredError(error: unknown): error is StructuredError { + return ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' + ); +} + +/** + * Maps Gemini usageMetadata to Usage. + */ +export function mapUsage( + metadata: { + promptTokenCount?: number; + candidatesTokenCount?: number; + cachedContentTokenCount?: number; + }, + model?: string, +): Usage { + return { + model: model ?? 'unknown', + inputTokens: metadata.promptTokenCount, + outputTokens: metadata.candidatesTokenCount, + cachedTokens: metadata.cachedContentTokenCount, + }; +} diff --git a/packages/core/src/agent/mock.test.ts b/packages/core/src/agent/mock.test.ts index 4f102d5dbd..f5138e388a 100644 --- a/packages/core/src/agent/mock.test.ts +++ b/packages/core/src/agent/mock.test.ts @@ -235,7 +235,7 @@ describe('MockAgentProtocol', () => { expect(streamId).toBeNull(); expect(session.events).toHaveLength(1); expect(session.events[0].type).toBe('session_update'); - expect(session.events[0].streamId).toBeNull(); + expect(session.events[0].streamId).toEqual(expect.any(String)); }); it('should throw on action', async () => { diff --git a/packages/core/src/agent/mock.ts b/packages/core/src/agent/mock.ts index f29e87f878..80d8ebae2f 100644 --- a/packages/core/src/agent/mock.ts +++ b/packages/core/src/agent/mock.ts @@ -8,8 +8,8 @@ import type { AgentEvent, AgentEventCommon, AgentEventData, - AgentSend, AgentProtocol, + AgentSend, Unsubscribe, } from './types.js'; @@ -86,12 +86,7 @@ export class MockAgentProtocol implements AgentProtocol { ) { const now = new Date().toISOString(); for (const eventData of events) { - const event: AgentEvent = { - ...eventData, - id: eventData.id ?? `e-${this._nextEventId++}`, - timestamp: eventData.timestamp ?? now, - streamId: eventData.streamId ?? streamId, - } as AgentEvent; + const event = this._normalizeEvent(eventData, now, streamId); this._emit(event); } @@ -99,13 +94,13 @@ export class MockAgentProtocol implements AgentProtocol { options?.close && !events.some((eventData) => eventData.type === 'agent_end') ) { - this._emit({ - id: `e-${this._nextEventId++}`, - timestamp: now, - streamId, - type: 'agent_end', - reason: 'completed', - } as AgentEvent); + this._emit( + this._normalizeEvent( + { type: 'agent_end', reason: 'completed' }, + now, + streamId, + ), + ); } } @@ -123,15 +118,18 @@ export class MockAgentProtocol implements AgentProtocol { const now = new Date().toISOString(); const eventsToEmit: AgentEvent[] = []; + let fallbackStreamId: string | undefined; - // Helper to normalize and prepare for emission + // All emitted events stay correlated to a stream even if this send does not + // start agent activity and therefore returns `streamId: null`. const normalize = (eventData: MockAgentEvent): AgentEvent => - ({ - ...eventData, - id: eventData.id ?? `e-${this._nextEventId++}`, - timestamp: eventData.timestamp ?? now, - streamId: eventData.streamId ?? streamId, - }) as AgentEvent; + this._normalizeEvent( + eventData, + now, + eventData.streamId ?? + streamId ?? + (fallbackStreamId ??= `mock-stream-${this._nextStreamId++}`), + ); // 1. User/Update event (BEFORE agent_start) if ('message' in payload && payload.message) { @@ -223,16 +221,32 @@ export class MockAgentProtocol implements AgentProtocol { return { streamId }; } + private _normalizeEvent( + eventData: MockAgentEvent, + timestamp: string, + streamId: string, + ): AgentEvent { + // TypeScript loses the specific union member when we add common event + // fields here, so keep the narrowing local to this mock-only helper. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return { + ...eventData, + id: eventData.id ?? `e-${this._nextEventId++}`, + timestamp: eventData.timestamp ?? timestamp, + streamId: eventData.streamId ?? streamId, + } as AgentEvent; + } + async abort(): Promise { if (this._lastStreamId && this._activeStreamIds.has(this._lastStreamId)) { const streamId = this._lastStreamId; - this._emit({ - id: `e-${this._nextEventId++}`, - timestamp: new Date().toISOString(), - streamId, - type: 'agent_end', - reason: 'aborted', - } as AgentEvent); + this._emit( + this._normalizeEvent( + { type: 'agent_end', reason: 'aborted' }, + new Date().toISOString(), + streamId, + ), + ); } } } diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 3b1c740ad4..4ec369d066 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -11,9 +11,10 @@ export type Unsubscribe = () => void; export interface AgentProtocol extends Trajectory { /** * Send data to the agent. Promise resolves when action is acknowledged. - * Returns the `streamId` of the stream the message was correlated to -- - * this may be a new stream if idle, an existing stream, or null if no - * stream was triggered. + * Returns the agent-activity `streamId` affected by the send. This may be a + * new stream if idle, an existing stream, or null if the send was + * acknowledged without starting agent activity. Emitted events should still + * remain correlated to a stream via their `streamId`. * * When a new stream is created by a send, the streamId MUST be returned * before the `agent_start` event is emitted for the stream. @@ -36,7 +37,7 @@ export interface AgentProtocol extends Trajectory { /** * AgentProtocol implements the Trajectory interface and can retrieve existing events. */ - readonly events: AgentEvent[]; + readonly events: readonly AgentEvent[]; } type RequireExactlyOne = { @@ -54,7 +55,7 @@ interface AgentSendPayloads { export type AgentSend = RequireExactlyOne & WithMeta; export interface Trajectory { - readonly events: AgentEvent[]; + readonly events: readonly AgentEvent[]; } export interface AgentEventCommon { @@ -62,8 +63,8 @@ export interface AgentEventCommon { id: string; /** Identifies the subagent thread, omitted for "main thread" events. */ threadId?: string; - /** Identifies a particular stream of a particular thread. */ - streamId?: string | null; + /** Identifies the stream this event belongs to. */ + streamId: string; /** ISO Timestamp for the time at which the event occurred. */ timestamp: string; /** The concrete type of the event. */ @@ -81,9 +82,18 @@ export type AgentEventData< EventType extends keyof AgentEvents = keyof AgentEvents, > = AgentEvents[EventType] & { type: EventType }; +/** + * Mapped type that produces a proper discriminated union when `EventType` is + * the default (all keys), enabling `switch (event.type)` narrowing. + * When a specific EventType is provided, resolves to a single variant. + */ export type AgentEvent< EventType extends keyof AgentEvents = keyof AgentEvents, -> = AgentEventCommon & AgentEventData; +> = { + [K in EventType]: AgentEventCommon & AgentEvents[K] & { type: K }; +}[EventType]; + +export type AgentEventType = keyof AgentEvents; export interface AgentEvents { /** MUST be the first event emitted in a session. */ @@ -263,7 +273,7 @@ export interface AgentStart { streamId: string; } -type StreamEndReason = +export type StreamEndReason = | 'completed' | 'failed' | 'aborted' diff --git a/packages/core/src/config/config-agents-reload.test.ts b/packages/core/src/config/config-agents-reload.test.ts new file mode 100644 index 0000000000..4fe39f7de8 --- /dev/null +++ b/packages/core/src/config/config-agents-reload.test.ts @@ -0,0 +1,246 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Config, type ConfigParameters } from './config.js'; +import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { SubagentTool } from '../agents/subagent-tool.js'; + +// Mock minimum dependencies that have side effects or external calls +vi.mock('../core/client.js', () => ({ + GeminiClient: vi.fn().mockImplementation(() => ({ + initialize: vi.fn().mockResolvedValue(undefined), + isInitialized: vi.fn().mockReturnValue(true), + setTools: vi.fn().mockResolvedValue(undefined), + updateSystemInstruction: vi.fn(), + })), +})); + +vi.mock('../core/contentGenerator.js'); +vi.mock('../telemetry/index.js'); +vi.mock('../core/tokenLimits.js'); +vi.mock('../services/fileDiscoveryService.js'); +vi.mock('../services/gitService.js'); +vi.mock('../services/trackerService.js'); + +describe('Config Agents Reload Integration', () => { + let tmpDir: string; + + beforeEach(async () => { + // Create a temporary directory for the test + tmpDir = await createTmpDir({}); + + // Create the .gemini/agents directory structure + await fs.mkdir(path.join(tmpDir, '.gemini', 'agents'), { recursive: true }); + }); + + afterEach(async () => { + await cleanupTmpDir(tmpDir); + vi.clearAllMocks(); + }); + + it('should unregister subagents as tools when they are disabled after being enabled', async () => { + const agentName = 'test-agent'; + const agentPath = path.join(tmpDir, '.gemini', 'agents', `${agentName}.md`); + + // Create agent definition file + const agentContent = `--- +name: ${agentName} +description: Test Agent Description +tools: [] +--- +Test System Prompt`; + + await fs.writeFile(agentPath, agentContent); + + // Initialize Config with agent enabled to start + const baseParams: ConfigParameters = { + sessionId: 'test-session', + targetDir: tmpDir, + model: 'test-model', + cwd: tmpDir, + debugMode: false, + enableAgents: true, + agents: { + overrides: { + [agentName]: { enabled: true }, + }, + }, + }; + + const config = new Config(baseParams); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + vi.spyOn( + config.getAcknowledgedAgentsService(), + 'isAcknowledged', + ).mockResolvedValue(true); + await config.initialize(); + + const toolRegistry = config.getToolRegistry(); + + // Verify the tool was registered initially + // Note: Subagent tools use the agent name as the tool name. + const initialTools = toolRegistry.getAllToolNames(); + expect(initialTools).toContain(agentName); + const toolInstance = toolRegistry.getTool(agentName); + expect(toolInstance).toBeInstanceOf(SubagentTool); + + // Disable agent in settings for reload simulation + vi.spyOn(config, 'getAgentsSettings').mockReturnValue({ + overrides: { + [agentName]: { enabled: false }, + }, + }); + + // Trigger the refresh action that follows reloading + // @ts-expect-error accessing private method for testing + await config.onAgentsRefreshed(); + + // 4. Verify the tool is UNREGISTERED + const finalTools = toolRegistry.getAllToolNames(); + expect(finalTools).not.toContain(agentName); + expect(toolRegistry.getTool(agentName)).toBeUndefined(); + }); + + it('should not register subagents as tools when agents are disabled from the start', async () => { + const agentName = 'test-agent-disabled'; + const agentPath = path.join(tmpDir, '.gemini', 'agents', `${agentName}.md`); + + const agentContent = `--- +name: ${agentName} +description: Test Agent Description +tools: [] +--- +Test System Prompt`; + + await fs.writeFile(agentPath, agentContent); + + const params: ConfigParameters = { + sessionId: 'test-session', + targetDir: tmpDir, + model: 'test-model', + cwd: tmpDir, + debugMode: false, + enableAgents: true, + agents: { + overrides: { + [agentName]: { enabled: false }, + }, + }, + }; + + const config = new Config(params); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + vi.spyOn( + config.getAcknowledgedAgentsService(), + 'isAcknowledged', + ).mockResolvedValue(true); + await config.initialize(); + + const toolRegistry = config.getToolRegistry(); + + const tools = toolRegistry.getAllToolNames(); + expect(tools).not.toContain(agentName); + expect(toolRegistry.getTool(agentName)).toBeUndefined(); + }); + + it('should register subagents as tools even when they are not in allowedTools', async () => { + const agentName = 'test-agent-allowed'; + const agentPath = path.join(tmpDir, '.gemini', 'agents', `${agentName}.md`); + + const agentContent = `--- +name: ${agentName} +description: Test Agent Description +tools: [] +--- +Test System Prompt`; + + await fs.writeFile(agentPath, agentContent); + + const params: ConfigParameters = { + sessionId: 'test-session', + targetDir: tmpDir, + model: 'test-model', + cwd: tmpDir, + debugMode: false, + enableAgents: true, + allowedTools: ['ls'], // test-agent-allowed is NOT here + agents: { + overrides: { + [agentName]: { enabled: true }, + }, + }, + }; + + const config = new Config(params); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + vi.spyOn( + config.getAcknowledgedAgentsService(), + 'isAcknowledged', + ).mockResolvedValue(true); + await config.initialize(); + + const toolRegistry = config.getToolRegistry(); + + const tools = toolRegistry.getAllToolNames(); + expect(tools).toContain(agentName); + }); + + it('should register subagents as tools when they are enabled after being disabled', async () => { + const agentName = 'test-agent-enable'; + const agentPath = path.join(tmpDir, '.gemini', 'agents', `${agentName}.md`); + + const agentContent = `--- +name: ${agentName} +description: Test Agent Description +tools: [] +--- +Test System Prompt`; + + await fs.writeFile(agentPath, agentContent); + + const params: ConfigParameters = { + sessionId: 'test-session', + targetDir: tmpDir, + model: 'test-model', + cwd: tmpDir, + debugMode: false, + enableAgents: true, + agents: { + overrides: { + [agentName]: { enabled: false }, + }, + }, + }; + + const config = new Config(params); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + vi.spyOn( + config.getAcknowledgedAgentsService(), + 'isAcknowledged', + ).mockResolvedValue(true); + await config.initialize(); + + const toolRegistry = config.getToolRegistry(); + + expect(toolRegistry.getAllToolNames()).not.toContain(agentName); + + // Enable agent in settings for reload simulation + vi.spyOn(config, 'getAgentsSettings').mockReturnValue({ + overrides: { + [agentName]: { enabled: true }, + }, + }); + + // Trigger refresh + // @ts-expect-error accessing private method for testing + await config.onAgentsRefreshed(); + + expect(toolRegistry.getAllToolNames()).toContain(agentName); + }); +}); diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index e1db5c6e8e..f8247f8377 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -185,6 +185,7 @@ vi.mock('../agents/registry.js', () => { const AgentRegistryMock = vi.fn(); AgentRegistryMock.prototype.initialize = vi.fn(); AgentRegistryMock.prototype.getAllDefinitions = vi.fn(() => []); + AgentRegistryMock.prototype.getAllDiscoveredAgentNames = vi.fn(() => []); AgentRegistryMock.prototype.getDefinition = vi.fn(); return { AgentRegistry: AgentRegistryMock }; }); @@ -1237,124 +1238,6 @@ describe('Server Config (config.ts)', () => { expect(wasReadFileToolRegistered).toBe(false); }); - it('should register subagents as tools when agents.overrides.codebase_investigator.enabled is true', async () => { - const params: ConfigParameters = { - ...baseParams, - agents: { - overrides: { - codebase_investigator: { enabled: true }, - }, - }, - }; - const config = new Config(params); - - const mockAgentDefinition = { - name: 'codebase_investigator', - description: 'Agent 1', - instructions: 'Inst 1', - }; - - const AgentRegistryMock = ( - (await vi.importMock('../agents/registry.js')) as { - AgentRegistry: Mock; - } - ).AgentRegistry; - AgentRegistryMock.prototype.getDefinition.mockReturnValue( - mockAgentDefinition, - ); - AgentRegistryMock.prototype.getAllDefinitions.mockReturnValue([ - mockAgentDefinition, - ]); - - const SubAgentToolMock = ( - (await vi.importMock('../agents/subagent-tool.js')) as { - SubagentTool: Mock; - } - ).SubagentTool; - - await config.initialize(); - - const registerToolMock = ( - (await vi.importMock('../tools/tool-registry')) as { - ToolRegistry: { prototype: { registerTool: Mock } }; - } - ).ToolRegistry.prototype.registerTool; - - expect(SubAgentToolMock).toHaveBeenCalledTimes(1); - expect(SubAgentToolMock).toHaveBeenCalledWith( - expect.anything(), // AgentRegistry - config, - expect.anything(), // MessageBus - ); - - const calls = registerToolMock.mock.calls; - const registeredWrappers = calls.filter( - (call) => call[0] instanceof SubAgentToolMock, - ); - expect(registeredWrappers).toHaveLength(1); - }); - - it('should register subagents as tools even when they are not in allowedTools', async () => { - const params: ConfigParameters = { - ...baseParams, - allowedTools: ['read_file'], // codebase_investigator is NOT here - agents: { - overrides: { - codebase_investigator: { enabled: true }, - }, - }, - }; - const config = new Config(params); - - const mockAgentDefinition = { - name: 'codebase_investigator', - description: 'Agent 1', - instructions: 'Inst 1', - }; - - const AgentRegistryMock = ( - (await vi.importMock('../agents/registry.js')) as { - AgentRegistry: Mock; - } - ).AgentRegistry; - AgentRegistryMock.prototype.getAllDefinitions.mockReturnValue([ - mockAgentDefinition, - ]); - - const SubAgentToolMock = ( - (await vi.importMock('../agents/subagent-tool.js')) as { - SubagentTool: Mock; - } - ).SubagentTool; - - await config.initialize(); - - expect(SubAgentToolMock).toHaveBeenCalled(); - }); - - it('should not register subagents as tools when agents are disabled', async () => { - const params: ConfigParameters = { - ...baseParams, - agents: { - overrides: { - codebase_investigator: { enabled: false }, - cli_help: { enabled: false }, - }, - }, - }; - const config = new Config(params); - - const SubAgentToolMock = ( - (await vi.importMock('../agents/subagent-tool.js')) as { - SubagentTool: Mock; - } - ).SubagentTool; - - await config.initialize(); - - expect(SubAgentToolMock).not.toHaveBeenCalled(); - }); - it('should register EnterPlanModeTool and ExitPlanModeTool when plan is enabled', async () => { const params: ConfigParameters = { ...baseParams, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 051c56228e..e52a286e7a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1001,7 +1001,7 @@ export class Config implements McpContext, AgentLoopContext { this.model = params.model; this.disableLoopDetection = params.disableLoopDetection ?? false; this._activeModel = params.model; - this.enableAgents = params.enableAgents ?? true; + this.enableAgents = params.enableAgents ?? false; this.agents = params.agents ?? {}; this.disableLLMCorrection = params.disableLLMCorrection ?? true; this.planEnabled = params.plan ?? true; @@ -3301,9 +3301,28 @@ export class Config implements McpContext, AgentLoopContext { */ private registerSubAgentTools(registry: ToolRegistry): void { const agentsOverrides = this.getAgentsSettings().overrides ?? {}; - const definitions = this.agentRegistry.getAllDefinitions(); + const discoveredDefinitions = + this.agentRegistry.getAllDiscoveredAgentNames(); - for (const definition of definitions) { + // First, unregister any agents that are now disabled + for (const agentName of discoveredDefinitions) { + if ( + !this.isAgentsEnabled() || + agentsOverrides[agentName]?.enabled === false + ) { + const tool = registry.getTool(agentName); + if (tool instanceof SubagentTool) { + registry.unregisterTool(agentName); + } + } + } + + const discoveredNames = this.agentRegistry.getAllDiscoveredAgentNames(); + for (const agentName of discoveredNames) { + const definition = this.agentRegistry.getDiscoveredDefinition(agentName); + if (!definition) { + continue; + } try { if ( !this.isAgentsEnabled() || diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts index 4b1237b167..d3864d8278 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts @@ -4,24 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { LinuxSandboxManager } from './LinuxSandboxManager.js'; import type { SandboxRequest } from '../../services/sandboxManager.js'; describe('LinuxSandboxManager', () => { const workspace = '/home/user/workspace'; + let manager: LinuxSandboxManager; - it('correctly outputs bwrap as the program with appropriate isolation flags', async () => { - const manager = new LinuxSandboxManager({ workspace }); - const req: SandboxRequest = { - command: 'ls', - args: ['-la'], - cwd: workspace, - env: {}, - }; + beforeEach(() => { + manager = new LinuxSandboxManager({ workspace }); + }); + const getBwrapArgs = async (req: SandboxRequest) => { const result = await manager.prepareCommand(req); - expect(result.program).toBe('sh'); expect(result.args[0]).toBe('-c'); expect(result.args[1]).toBe( @@ -29,8 +25,17 @@ describe('LinuxSandboxManager', () => { ); expect(result.args[2]).toBe('_'); expect(result.args[3]).toMatch(/gemini-cli-seccomp-.*\.bpf$/); + return result.args.slice(4); + }; + + it('correctly outputs bwrap as the program with appropriate isolation flags', async () => { + const bwrapArgs = await getBwrapArgs({ + command: 'ls', + args: ['-la'], + cwd: workspace, + env: {}, + }); - const bwrapArgs = result.args.slice(4); expect(bwrapArgs).toEqual([ '--unshare-all', '--new-session', @@ -56,55 +61,48 @@ describe('LinuxSandboxManager', () => { }); it('maps allowedPaths to bwrap binds', async () => { - const manager = new LinuxSandboxManager({ - workspace, - allowedPaths: ['/tmp/cache', '/opt/tools', workspace], - }); - const req: SandboxRequest = { + const bwrapArgs = await getBwrapArgs({ command: 'node', args: ['script.js'], cwd: workspace, env: {}, - }; + policy: { + allowedPaths: ['/tmp/cache', '/opt/tools', workspace], + }, + }); - const result = await manager.prepareCommand(req); + // Verify the specific bindings were added correctly + const bindsIndex = bwrapArgs.indexOf('--seccomp'); + const binds = bwrapArgs.slice(bwrapArgs.indexOf('--bind'), bindsIndex); - expect(result.program).toBe('sh'); - expect(result.args[0]).toBe('-c'); - expect(result.args[1]).toBe( - 'bpf_path="$1"; shift; exec bwrap "$@" 9< "$bpf_path"', - ); - expect(result.args[2]).toBe('_'); - expect(result.args[3]).toMatch(/gemini-cli-seccomp-.*\.bpf$/); - - const bwrapArgs = result.args.slice(4); - expect(bwrapArgs).toEqual([ - '--unshare-all', - '--new-session', - '--die-with-parent', - '--ro-bind', - '/', - '/', - '--dev', - '/dev', - '--proc', - '/proc', - '--tmpfs', - '/tmp', + expect(binds).toEqual([ '--bind', workspace, workspace, - '--bind', + '--bind-try', '/tmp/cache', '/tmp/cache', - '--bind', + '--bind-try', '/opt/tools', '/opt/tools', - '--seccomp', - '9', - '--', - 'node', - 'script.js', ]); }); + + it('should not bind the workspace twice even if it has a trailing slash in allowedPaths', async () => { + const bwrapArgs = await getBwrapArgs({ + command: 'ls', + args: ['-la'], + cwd: workspace, + env: {}, + policy: { + allowedPaths: [workspace + '/'], + }, + }); + + const bindsIndex = bwrapArgs.indexOf('--seccomp'); + const binds = bwrapArgs.slice(bwrapArgs.indexOf('--bind'), bindsIndex); + + // Should only contain the primary workspace bind, not the second one with a trailing slash + expect(binds).toEqual(['--bind', workspace, workspace]); + }); }); diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts index db75eb2dfa..f9f0ed68e9 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts @@ -4,18 +4,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { join } from 'node:path'; +import { join, normalize } from 'node:path'; import { writeFileSync } from 'node:fs'; import os from 'node:os'; import { type SandboxManager, + type GlobalSandboxOptions, type SandboxRequest, type SandboxedCommand, + sanitizePaths, } from '../../services/sandboxManager.js'; import { sanitizeEnvironment, getSecureSanitizationConfig, - type EnvironmentSanitizationConfig, } from '../../services/environmentSanitization.js'; let cachedBpfPath: string | undefined; @@ -76,28 +77,15 @@ function getSeccompBpfPath(): string { return bpfPath; } -/** - * Options for configuring the LinuxSandboxManager. - */ -export interface LinuxSandboxOptions { - /** The primary workspace path to bind into the sandbox. */ - workspace: string; - /** Additional paths to bind into the sandbox. */ - allowedPaths?: string[]; - /** Optional base sanitization config. */ - sanitizationConfig?: EnvironmentSanitizationConfig; -} - /** * A SandboxManager implementation for Linux that uses Bubblewrap (bwrap). */ export class LinuxSandboxManager implements SandboxManager { - constructor(private readonly options: LinuxSandboxOptions) {} + constructor(private readonly options: GlobalSandboxOptions) {} async prepareCommand(req: SandboxRequest): Promise { const sanitizationConfig = getSecureSanitizationConfig( - req.config?.sanitizationConfig, - this.options.sanitizationConfig, + req.policy?.sanitizationConfig, ); const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); @@ -121,13 +109,20 @@ export class LinuxSandboxManager implements SandboxManager { this.options.workspace, ]; - const allowedPaths = this.options.allowedPaths ?? []; - for (const path of allowedPaths) { - if (path !== this.options.workspace) { - bwrapArgs.push('--bind', path, path); + const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || []; + const normalizedWorkspace = normalize(this.options.workspace).replace( + /\/$/, + '', + ); + for (const allowedPath of allowedPaths) { + const normalizedAllowedPath = normalize(allowedPath).replace(/\/$/, ''); + if (normalizedAllowedPath !== normalizedWorkspace) { + bwrapArgs.push('--bind-try', allowedPath, allowedPath); } } + // TODO: handle forbidden paths + const bpfPath = getSeccompBpfPath(); bwrapArgs.push('--seccomp', '9'); diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.integration.test.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.integration.test.ts index d9776bc715..f9a3551124 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.integration.test.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.integration.test.ts @@ -116,7 +116,6 @@ describe.skipIf(os.platform() !== 'darwin')( try { const manager = new MacOsSandboxManager({ workspace: process.cwd(), - allowedPaths: [allowedDir], }); const testFile = path.join(allowedDir, 'test.txt'); @@ -125,6 +124,9 @@ describe.skipIf(os.platform() !== 'darwin')( args: [testFile], cwd: process.cwd(), env: process.env, + policy: { + allowedPaths: [allowedDir], + }, }); const execResult = await runCommand(command); @@ -183,13 +185,15 @@ describe.skipIf(os.platform() !== 'darwin')( it('should grant network access when explicitly allowed', async () => { const manager = new MacOsSandboxManager({ workspace: process.cwd(), - networkAccess: true, }); const command = await manager.prepareCommand({ command: 'curl', args: ['-s', '--connect-timeout', '1', testServerUrl], cwd: process.cwd(), env: process.env, + policy: { + networkAccess: true, + }, }); const execResult = await runCommand(command); diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts index 69946daade..d6a72e8439 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts @@ -3,105 +3,182 @@ * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeEach, - afterEach, - type MockInstance, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { MacOsSandboxManager } from './MacOsSandboxManager.js'; -import * as seatbeltArgsBuilder from './seatbeltArgsBuilder.js'; +import type { ExecutionPolicy } from '../../services/sandboxManager.js'; +import fs from 'node:fs'; +import os from 'node:os'; describe('MacOsSandboxManager', () => { const mockWorkspace = '/test/workspace'; const mockAllowedPaths = ['/test/allowed']; const mockNetworkAccess = true; + const mockPolicy: ExecutionPolicy = { + allowedPaths: mockAllowedPaths, + networkAccess: mockNetworkAccess, + }; + let manager: MacOsSandboxManager; - let buildArgsSpy: MockInstance; beforeEach(() => { - manager = new MacOsSandboxManager({ - workspace: mockWorkspace, - allowedPaths: mockAllowedPaths, - networkAccess: mockNetworkAccess, - }); - - buildArgsSpy = vi - .spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs') - .mockReturnValue([ - '-p', - '(mock profile)', - '-D', - 'WORKSPACE=/test/workspace', - ]); + manager = new MacOsSandboxManager({ workspace: mockWorkspace }); + // Mock realpathSync to just return the path for testing + vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string); }); afterEach(() => { vi.restoreAllMocks(); }); - it('should correctly invoke buildSeatbeltArgs with the configured options', async () => { - await manager.prepareCommand({ - command: 'echo', - args: ['hello'], - cwd: mockWorkspace, - env: {}, + describe('prepareCommand', () => { + it('should build a strict allowlist profile allowing the workspace via param', async () => { + const result = await manager.prepareCommand({ + command: 'echo', + args: ['hello'], + cwd: mockWorkspace, + env: {}, + policy: { networkAccess: false }, + }); + + expect(result.program).toBe('/usr/bin/sandbox-exec'); + const profile = result.args[1]; + expect(profile).toContain('(version 1)'); + expect(profile).toContain('(deny default)'); + expect(profile).toContain('(allow process-exec)'); + expect(profile).toContain('(subpath (param "WORKSPACE"))'); + expect(profile).not.toContain('(allow network*)'); + + expect(result.args).toContain('-D'); + expect(result.args).toContain('WORKSPACE=/test/workspace'); + expect(result.args).toContain(`TMPDIR=${os.tmpdir()}`); }); - expect(buildArgsSpy).toHaveBeenCalledWith({ - workspace: mockWorkspace, - allowedPaths: mockAllowedPaths, - networkAccess: mockNetworkAccess, - }); - }); + it('should allow network when networkAccess is true in policy', async () => { + const result = await manager.prepareCommand({ + command: 'curl', + args: ['example.com'], + cwd: mockWorkspace, + env: {}, + policy: { networkAccess: true }, + }); - it('should format the executable and arguments correctly for sandbox-exec', async () => { - const result = await manager.prepareCommand({ - command: 'echo', - args: ['hello'], - cwd: mockWorkspace, - env: {}, + const profile = result.args[1]; + expect(profile).toContain('(allow network*)'); }); - expect(result.program).toBe('/usr/bin/sandbox-exec'); - expect(result.args).toEqual([ - '-p', - '(mock profile)', - '-D', - 'WORKSPACE=/test/workspace', - '--', - 'echo', - 'hello', - ]); - }); + it('should parameterize allowed paths and normalize them', async () => { + vi.spyOn(fs, 'realpathSync').mockImplementation((p) => { + if (p === '/test/symlink') return '/test/real_path'; + return p as string; + }); - it('should correctly pass through the cwd to the resulting command', async () => { - const result = await manager.prepareCommand({ - command: 'echo', - args: ['hello'], - cwd: '/test/different/cwd', - env: {}, + const result = await manager.prepareCommand({ + command: 'ls', + args: ['/custom/path1'], + cwd: mockWorkspace, + env: {}, + policy: { + allowedPaths: ['/custom/path1', '/test/symlink'], + }, + }); + + const profile = result.args[1]; + expect(profile).toContain('(subpath (param "ALLOWED_PATH_0"))'); + expect(profile).toContain('(subpath (param "ALLOWED_PATH_1"))'); + + expect(result.args).toContain('-D'); + expect(result.args).toContain('ALLOWED_PATH_0=/custom/path1'); + expect(result.args).toContain('ALLOWED_PATH_1=/test/real_path'); }); - expect(result.cwd).toBe('/test/different/cwd'); - }); + it('should format the executable and arguments correctly for sandbox-exec', async () => { + const result = await manager.prepareCommand({ + command: 'echo', + args: ['hello'], + cwd: mockWorkspace, + env: {}, + policy: mockPolicy, + }); - it('should apply environment sanitization via the default mechanisms', async () => { - const result = await manager.prepareCommand({ - command: 'echo', - args: ['hello'], - cwd: mockWorkspace, - env: { - SAFE_VAR: '1', - GITHUB_TOKEN: 'sensitive', - }, + expect(result.program).toBe('/usr/bin/sandbox-exec'); + expect(result.args.slice(-3)).toEqual(['--', 'echo', 'hello']); }); - expect(result.env['SAFE_VAR']).toBe('1'); - expect(result.env['GITHUB_TOKEN']).toBeUndefined(); + it('should correctly pass through the cwd to the resulting command', async () => { + const result = await manager.prepareCommand({ + command: 'echo', + args: ['hello'], + cwd: '/test/different/cwd', + env: {}, + policy: mockPolicy, + }); + + expect(result.cwd).toBe('/test/different/cwd'); + }); + + it('should apply environment sanitization via the default mechanisms', async () => { + const result = await manager.prepareCommand({ + command: 'echo', + args: ['hello'], + cwd: mockWorkspace, + env: { + SAFE_VAR: '1', + GITHUB_TOKEN: 'sensitive', + }, + policy: mockPolicy, + }); + + expect(result.env['SAFE_VAR']).toBe('1'); + expect(result.env['GITHUB_TOKEN']).toBeUndefined(); + }); + + it('should resolve parent directories if a file does not exist', async () => { + vi.spyOn(fs, 'realpathSync').mockImplementation((p) => { + if (p === '/test/symlink/nonexistent.txt') { + const error = new Error('ENOENT'); + Object.assign(error, { code: 'ENOENT' }); + throw error; + } + if (p === '/test/symlink') { + return '/test/real_path'; + } + return p as string; + }); + + const dynamicManager = new MacOsSandboxManager({ + workspace: '/test/symlink/nonexistent.txt', + }); + const dynamicResult = await dynamicManager.prepareCommand({ + command: 'echo', + args: ['hello'], + cwd: '/test/symlink/nonexistent.txt', + env: {}, + }); + + expect(dynamicResult.args).toContain( + 'WORKSPACE=/test/real_path/nonexistent.txt', + ); + }); + + it('should throw if realpathSync throws a non-ENOENT error', async () => { + vi.spyOn(fs, 'realpathSync').mockImplementation(() => { + const error = new Error('Permission denied'); + Object.assign(error, { code: 'EACCES' }); + throw error; + }); + + const errorManager = new MacOsSandboxManager({ + workspace: '/test/workspace', + }); + await expect( + errorManager.prepareCommand({ + command: 'echo', + args: ['hello'], + cwd: mockWorkspace, + env: {}, + }), + ).rejects.toThrow('Permission denied'); + }); }); }); diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts index a212b310b2..06eabd2a94 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts @@ -4,51 +4,40 @@ * SPDX-License-Identifier: Apache-2.0 */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { type SandboxManager, + type GlobalSandboxOptions, type SandboxRequest, type SandboxedCommand, + type ExecutionPolicy, + sanitizePaths, } from '../../services/sandboxManager.js'; import { sanitizeEnvironment, getSecureSanitizationConfig, - type EnvironmentSanitizationConfig, } from '../../services/environmentSanitization.js'; -import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js'; - -/** - * Options for configuring the MacOsSandboxManager. - */ -export interface MacOsSandboxOptions { - /** The primary workspace path to allow access to within the sandbox. */ - workspace: string; - /** Additional paths to allow access to within the sandbox. */ - allowedPaths?: string[]; - /** Whether network access is allowed. */ - networkAccess?: boolean; - /** Optional base sanitization config. */ - sanitizationConfig?: EnvironmentSanitizationConfig; -} +import { + BASE_SEATBELT_PROFILE, + NETWORK_SEATBELT_PROFILE, +} from './baseProfile.js'; /** * A SandboxManager implementation for macOS that uses Seatbelt. */ export class MacOsSandboxManager implements SandboxManager { - constructor(private readonly options: MacOsSandboxOptions) {} + constructor(private readonly options: GlobalSandboxOptions) {} async prepareCommand(req: SandboxRequest): Promise { const sanitizationConfig = getSecureSanitizationConfig( - req.config?.sanitizationConfig, - this.options.sanitizationConfig, + req.policy?.sanitizationConfig, ); const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); - const sandboxArgs = buildSeatbeltArgs({ - workspace: this.options.workspace, - allowedPaths: this.options.allowedPaths, - networkAccess: this.options.networkAccess, - }); + const sandboxArgs = this.buildSeatbeltArgs(this.options, req.policy); return { program: '/usr/bin/sandbox-exec', @@ -57,4 +46,65 @@ export class MacOsSandboxManager implements SandboxManager { cwd: req.cwd, }; } + + /** + * Builds the arguments array for sandbox-exec using a strict allowlist profile. + * It relies on parameters passed to sandbox-exec via the -D flag to avoid + * string interpolation vulnerabilities, and normalizes paths against symlink escapes. + * + * Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '', '-D', ...]) + * Does not include the final '--' separator or the command to run. + */ + private buildSeatbeltArgs( + options: GlobalSandboxOptions, + policy?: ExecutionPolicy, + ): string[] { + const profileLines = [BASE_SEATBELT_PROFILE]; + const args: string[] = []; + + const workspacePath = this.tryRealpath(options.workspace); + args.push('-D', `WORKSPACE=${workspacePath}`); + + const tmpPath = this.tryRealpath(os.tmpdir()); + args.push('-D', `TMPDIR=${tmpPath}`); + + const allowedPaths = sanitizePaths(policy?.allowedPaths) || []; + for (let i = 0; i < allowedPaths.length; i++) { + const allowedPath = this.tryRealpath(allowedPaths[i]); + args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`); + profileLines.push( + `(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))`, + ); + } + + // TODO: handle forbidden paths + + if (policy?.networkAccess) { + profileLines.push(NETWORK_SEATBELT_PROFILE); + } + + args.unshift('-p', profileLines.join('\n')); + + return args; + } + + /** + * Resolves symlinks for a given path to prevent sandbox escapes. + * If a file does not exist (ENOENT), it recursively resolves the parent directory. + * Other errors (e.g. EACCES) are re-thrown. + */ + private tryRealpath(p: string): string { + try { + return fs.realpathSync(p); + } catch (e) { + if (e instanceof Error && 'code' in e && e.code === 'ENOENT') { + const parentDir = path.dirname(p); + if (parentDir === p) { + return p; + } + return path.join(this.tryRealpath(parentDir), path.basename(p)); + } + throw e; + } + } } diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts deleted file mode 100644 index 340eaead60..0000000000 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ -import { describe, it, expect, vi } from 'vitest'; -import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js'; -import fs from 'node:fs'; -import os from 'node:os'; - -describe('seatbeltArgsBuilder', () => { - it('should build a strict allowlist profile allowing the workspace via param', () => { - // Mock realpathSync to just return the path for testing - vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string); - - const args = buildSeatbeltArgs({ workspace: '/Users/test/workspace' }); - - expect(args[0]).toBe('-p'); - const profile = args[1]; - expect(profile).toContain('(version 1)'); - expect(profile).toContain('(deny default)'); - expect(profile).toContain('(allow process-exec)'); - expect(profile).toContain('(subpath (param "WORKSPACE"))'); - expect(profile).not.toContain('(allow network*)'); - - expect(args).toContain('-D'); - expect(args).toContain('WORKSPACE=/Users/test/workspace'); - expect(args).toContain(`TMPDIR=${os.tmpdir()}`); - - vi.restoreAllMocks(); - }); - - it('should allow network when networkAccess is true', () => { - const args = buildSeatbeltArgs({ workspace: '/test', networkAccess: true }); - const profile = args[1]; - expect(profile).toContain('(allow network*)'); - }); - - it('should parameterize allowed paths and normalize them', () => { - vi.spyOn(fs, 'realpathSync').mockImplementation((p) => { - if (p === '/test/symlink') return '/test/real_path'; - return p as string; - }); - - const args = buildSeatbeltArgs({ - workspace: '/test', - allowedPaths: ['/custom/path1', '/test/symlink'], - }); - - const profile = args[1]; - expect(profile).toContain('(subpath (param "ALLOWED_PATH_0"))'); - expect(profile).toContain('(subpath (param "ALLOWED_PATH_1"))'); - - expect(args).toContain('-D'); - expect(args).toContain('ALLOWED_PATH_0=/custom/path1'); - expect(args).toContain('ALLOWED_PATH_1=/test/real_path'); - - vi.restoreAllMocks(); - }); - - it('should resolve parent directories if a file does not exist', () => { - vi.spyOn(fs, 'realpathSync').mockImplementation((p) => { - if (p === '/test/symlink/nonexistent.txt') { - const error = new Error('ENOENT'); - Object.assign(error, { code: 'ENOENT' }); - throw error; - } - if (p === '/test/symlink') { - return '/test/real_path'; - } - return p as string; - }); - - const args = buildSeatbeltArgs({ - workspace: '/test/symlink/nonexistent.txt', - }); - - expect(args).toContain('WORKSPACE=/test/real_path/nonexistent.txt'); - vi.restoreAllMocks(); - }); - - it('should throw if realpathSync throws a non-ENOENT error', () => { - vi.spyOn(fs, 'realpathSync').mockImplementation(() => { - const error = new Error('Permission denied'); - Object.assign(error, { code: 'EACCES' }); - throw error; - }); - - expect(() => - buildSeatbeltArgs({ - workspace: '/test/workspace', - }), - ).toThrow('Permission denied'); - - vi.restoreAllMocks(); - }); -}); diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts deleted file mode 100644 index 0e162f22dd..0000000000 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { - BASE_SEATBELT_PROFILE, - NETWORK_SEATBELT_PROFILE, -} from './baseProfile.js'; - -/** - * Options for building macOS Seatbelt arguments. - */ -export interface SeatbeltArgsOptions { - /** The primary workspace path to allow access to. */ - workspace: string; - /** Additional paths to allow access to. */ - allowedPaths?: string[]; - /** Whether to allow network access. */ - networkAccess?: boolean; -} - -/** - * Resolves symlinks for a given path to prevent sandbox escapes. - * If a file does not exist (ENOENT), it recursively resolves the parent directory. - * Other errors (e.g. EACCES) are re-thrown. - */ -function tryRealpath(p: string): string { - try { - return fs.realpathSync(p); - } catch (e) { - if (e instanceof Error && 'code' in e && e.code === 'ENOENT') { - const parentDir = path.dirname(p); - if (parentDir === p) { - return p; - } - return path.join(tryRealpath(parentDir), path.basename(p)); - } - throw e; - } -} - -/** - * Builds the arguments array for sandbox-exec using a strict allowlist profile. - * It relies on parameters passed to sandbox-exec via the -D flag to avoid - * string interpolation vulnerabilities, and normalizes paths against symlink escapes. - * - * Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '', '-D', ...]) - * Does not include the final '--' separator or the command to run. - */ -export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] { - let profile = BASE_SEATBELT_PROFILE + '\n'; - const args: string[] = []; - - const workspacePath = tryRealpath(options.workspace); - args.push('-D', `WORKSPACE=${workspacePath}`); - - const tmpPath = tryRealpath(os.tmpdir()); - args.push('-D', `TMPDIR=${tmpPath}`); - - if (options.allowedPaths) { - for (let i = 0; i < options.allowedPaths.length; i++) { - const allowedPath = tryRealpath(options.allowedPaths[i]); - args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`); - profile += `(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))\n`; - } - } - - if (options.networkAccess) { - profile += NETWORK_SEATBELT_PROFILE; - } - - args.unshift('-p', profile); - - return args; -} diff --git a/packages/core/src/scheduler/scheduler_hooks.test.ts b/packages/core/src/scheduler/scheduler_hooks.test.ts new file mode 100644 index 0000000000..b59ffc4ace --- /dev/null +++ b/packages/core/src/scheduler/scheduler_hooks.test.ts @@ -0,0 +1,305 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { Scheduler } from './scheduler.js'; +import type { ErroredToolCall } from './types.js'; +import { CoreToolCallStatus } from './types.js'; +import type { Config, ToolRegistry, AgentLoopContext } from '../index.js'; +import { + ApprovalMode, + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, +} from '../index.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import { MockTool } from '../test-utils/mock-tool.js'; +import { DEFAULT_GEMINI_MODEL } from '../config/models.js'; +import type { PolicyEngine } from '../policy/policy-engine.js'; +import { HookSystem } from '../hooks/hookSystem.js'; +import { HookType, HookEventName } from '../hooks/types.js'; + +function createMockConfig(overrides: Partial = {}): Config { + const defaultToolRegistry = { + getTool: () => undefined, + getToolByName: () => undefined, + getFunctionDeclarations: () => [], + tools: new Map(), + discovery: {}, + registerTool: () => {}, + getToolByDisplayName: () => undefined, + getTools: () => [], + discoverTools: async () => {}, + getAllTools: () => [], + getToolsByServer: () => [], + getExperiments: () => {}, + } as unknown as ToolRegistry; + + const baseConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + getDebugMode: () => false, + isInteractive: () => true, + getApprovalMode: () => ApprovalMode.DEFAULT, + setApprovalMode: () => {}, + getAllowedTools: () => [], + getContentGeneratorConfig: () => ({ + model: 'test-model', + authType: 'oauth-personal', + }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + sanitizationConfig: { + enableEnvironmentVariableRedaction: true, + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + }, + }), + storage: { + getProjectTempDir: () => '/tmp', + }, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => 1000, + getToolRegistry: () => defaultToolRegistry, + getWorkingDir: () => '/mock/dir', + getActiveModel: () => DEFAULT_GEMINI_MODEL, + getGeminiClient: () => null, + getMessageBus: () => createMockMessageBus(), + getEnableHooks: () => true, + getExperiments: () => {}, + getPolicyEngine: () => + ({ + check: async () => ({ decision: 'allow' }), + }) as unknown as PolicyEngine, + } as unknown as Config; + + const mockConfig = Object.assign({}, baseConfig, overrides) as Config; + + (mockConfig as { config?: Config }).config = mockConfig; + + return mockConfig; +} + +describe('Scheduler Hooks', () => { + it('should stop execution if BeforeTool hook requests stop', async () => { + const executeFn = vi.fn().mockResolvedValue({ + llmContent: 'Tool executed', + returnDisplay: 'Tool executed', + }); + const mockTool = new MockTool({ name: 'mockTool', execute: executeFn }); + + const toolRegistry = { + getTool: () => mockTool, + getAllToolNames: () => ['mockTool'], + } as unknown as ToolRegistry; + + const mockMessageBus = createMockMessageBus(); + + const mockConfig = createMockConfig({ + getToolRegistry: () => toolRegistry, + getMessageBus: () => mockMessageBus, + getApprovalMode: () => ApprovalMode.YOLO, + }); + + const hookSystem = new HookSystem(mockConfig); + + (mockConfig as { getHookSystem?: () => HookSystem }).getHookSystem = () => + hookSystem; + + // Register a programmatic runtime hook + hookSystem.registerHook( + { + type: HookType.Runtime, + name: 'test-stop-hook', + action: async () => ({ + continue: false, + stopReason: 'Hook stopped execution', + }), + }, + HookEventName.BeforeTool, + ); + + const scheduler = new Scheduler({ + context: { + config: mockConfig, + messageBus: mockMessageBus, + toolRegistry, + } as unknown as AgentLoopContext, + getPreferredEditor: () => 'vscode', + schedulerId: 'test-scheduler', + }); + + const request = { + callId: '1', + name: 'mockTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }; + + const results = await scheduler.schedule( + [request], + new AbortController().signal, + ); + + expect(results.length).toBe(1); + const result = results[0]; + expect(result.status).toBe(CoreToolCallStatus.Error); + const erroredCall = result as ErroredToolCall; + + expect(erroredCall.response.error?.message).toContain( + 'Agent execution stopped by hook: Hook stopped execution', + ); + expect(executeFn).not.toHaveBeenCalled(); + }); + + it('should block tool execution if BeforeTool hook requests block', async () => { + const executeFn = vi.fn(); + const mockTool = new MockTool({ name: 'mockTool', execute: executeFn }); + + const toolRegistry = { + getTool: () => mockTool, + getAllToolNames: () => ['mockTool'], + } as unknown as ToolRegistry; + + const mockMessageBus = createMockMessageBus(); + + const mockConfig = createMockConfig({ + getToolRegistry: () => toolRegistry, + getMessageBus: () => mockMessageBus, + getApprovalMode: () => ApprovalMode.YOLO, + }); + + const hookSystem = new HookSystem(mockConfig); + + (mockConfig as { getHookSystem?: () => HookSystem }).getHookSystem = () => + hookSystem; + + hookSystem.registerHook( + { + type: HookType.Runtime, + name: 'test-block-hook', + action: async () => ({ + decision: 'block', + reason: 'Hook blocked execution', + }), + }, + HookEventName.BeforeTool, + ); + + const scheduler = new Scheduler({ + context: { + config: mockConfig, + messageBus: mockMessageBus, + toolRegistry, + } as unknown as AgentLoopContext, + getPreferredEditor: () => 'vscode', + schedulerId: 'test-scheduler', + }); + + const request = { + callId: '1', + name: 'mockTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }; + + const results = await scheduler.schedule( + [request], + new AbortController().signal, + ); + + expect(results.length).toBe(1); + const result = results[0]; + expect(result.status).toBe(CoreToolCallStatus.Error); + const erroredCall = result as ErroredToolCall; + + expect(erroredCall.response.error?.message).toContain( + 'Tool execution blocked: Hook blocked execution', + ); + expect(executeFn).not.toHaveBeenCalled(); + }); + + it('should update tool input if BeforeTool hook provides modified input', async () => { + const executeFn = vi.fn().mockResolvedValue({ + llmContent: 'Tool executed', + returnDisplay: 'Tool executed', + }); + const mockTool = new MockTool({ name: 'mockTool', execute: executeFn }); + + const toolRegistry = { + getTool: () => mockTool, + getAllToolNames: () => ['mockTool'], + } as unknown as ToolRegistry; + + const mockMessageBus = createMockMessageBus(); + + const mockConfig = createMockConfig({ + getToolRegistry: () => toolRegistry, + getMessageBus: () => mockMessageBus, + getApprovalMode: () => ApprovalMode.YOLO, + }); + + const hookSystem = new HookSystem(mockConfig); + + (mockConfig as { getHookSystem?: () => HookSystem }).getHookSystem = () => + hookSystem; + + hookSystem.registerHook( + { + type: HookType.Runtime, + name: 'test-modify-input-hook', + action: async () => ({ + continue: true, + hookSpecificOutput: { + hookEventName: 'BeforeTool', + tool_input: { newParam: 'modifiedValue' }, + }, + }), + }, + HookEventName.BeforeTool, + ); + + const scheduler = new Scheduler({ + context: { + config: mockConfig, + messageBus: mockMessageBus, + toolRegistry, + } as unknown as AgentLoopContext, + getPreferredEditor: () => 'vscode', + schedulerId: 'test-scheduler', + }); + + const request = { + callId: '1', + name: 'mockTool', + args: { originalParam: 'originalValue' }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }; + + const results = await scheduler.schedule( + [request], + new AbortController().signal, + ); + + expect(results.length).toBe(1); + const result = results[0]; + expect(result.status).toBe(CoreToolCallStatus.Success); + + expect(executeFn).toHaveBeenCalledWith( + { newParam: 'modifiedValue' }, + expect.anything(), + undefined, + expect.anything(), + ); + + expect(result.request.args).toEqual({ + newParam: 'modifiedValue', + }); + }); +}); diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index d201314d9f..50760ccf1c 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -6,12 +6,30 @@ import os from 'node:os'; import { describe, expect, it, vi } from 'vitest'; -import { NoopSandboxManager } from './sandboxManager.js'; +import { NoopSandboxManager, sanitizePaths } from './sandboxManager.js'; import { createSandboxManager } from './sandboxManagerFactory.js'; import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js'; import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js'; import { WindowsSandboxManager } from './windowsSandboxManager.js'; +describe('sanitizePaths', () => { + it('should return undefined if no paths are provided', () => { + expect(sanitizePaths(undefined)).toBeUndefined(); + }); + + it('should deduplicate paths and return them', () => { + const paths = ['/workspace/foo', '/workspace/bar', '/workspace/foo']; + expect(sanitizePaths(paths)).toEqual(['/workspace/foo', '/workspace/bar']); + }); + + it('should throw an error if a path is not absolute', () => { + const paths = ['/workspace/foo', 'relative/path']; + expect(() => sanitizePaths(paths)).toThrow( + 'Sandbox path must be absolute: relative/path', + ); + }); +}); + describe('NoopSandboxManager', () => { const sandboxManager = new NoopSandboxManager(); @@ -58,7 +76,7 @@ describe('NoopSandboxManager', () => { env: { API_KEY: 'sensitive-key', }, - config: { + policy: { sanitizationConfig: { enableEnvironmentVariableRedaction: false, }, @@ -80,7 +98,7 @@ describe('NoopSandboxManager', () => { MY_SAFE_VAR: 'safe-value', MY_TOKEN: 'secret-token', }, - config: { + policy: { sanitizationConfig: { allowedEnvironmentVariables: ['MY_SAFE_VAR', 'MY_TOKEN'], }, @@ -103,7 +121,7 @@ describe('NoopSandboxManager', () => { SAFE_VAR: 'safe-value', BLOCKED_VAR: 'blocked-value', }, - config: { + policy: { sanitizationConfig: { blockedEnvironmentVariables: ['BLOCKED_VAR'], }, diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index 8642edff11..0108c8f172 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -4,11 +4,37 @@ * SPDX-License-Identifier: Apache-2.0 */ +import os from 'node:os'; +import path from 'node:path'; import { sanitizeEnvironment, getSecureSanitizationConfig, type EnvironmentSanitizationConfig, } from './environmentSanitization.js'; +/** + * Security boundaries and permissions applied to a specific sandboxed execution. + */ +export interface ExecutionPolicy { + /** Additional absolute paths to grant full read/write access to. */ + allowedPaths?: string[]; + /** Absolute paths to explicitly deny read/write access to (overrides allowlists). */ + forbiddenPaths?: string[]; + /** Whether network access is allowed. */ + networkAccess?: boolean; + /** Rules for scrubbing sensitive environment variables. */ + sanitizationConfig?: Partial; +} + +/** + * Global configuration options used to initialize a SandboxManager. + */ +export interface GlobalSandboxOptions { + /** + * The primary workspace path the sandbox is anchored to. + * This directory is granted full read and write access. + */ + workspace: string; +} /** * Request for preparing a command to run in a sandbox. @@ -22,12 +48,8 @@ export interface SandboxRequest { cwd: string; /** Environment variables to be passed to the program. */ env: NodeJS.ProcessEnv; - /** Optional sandbox-specific configuration. */ - config?: { - sanitizationConfig?: Partial; - allowedPaths?: string[]; - networkAccess?: boolean; - }; + /** Policy to use for this request. */ + policy?: ExecutionPolicy; } /** @@ -65,7 +87,7 @@ export class NoopSandboxManager implements SandboxManager { */ async prepareCommand(req: SandboxRequest): Promise { const sanitizationConfig = getSecureSanitizationConfig( - req.config?.sanitizationConfig, + req.policy?.sanitizationConfig, ); const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); @@ -87,4 +109,35 @@ export class LocalSandboxManager implements SandboxManager { } } +/** + * Sanitizes an array of paths by deduplicating them and ensuring they are absolute. + */ +export function sanitizePaths(paths?: string[]): string[] | undefined { + if (!paths) return undefined; + + // We use a Map to deduplicate paths based on their normalized, + // platform-specific identity e.g. handling case-insensitivity on Windows) + // while preserving the original string casing. + const uniquePathsMap = new Map(); + for (const p of paths) { + if (!path.isAbsolute(p)) { + throw new Error(`Sandbox path must be absolute: ${p}`); + } + + // Normalize the path (resolves slashes and redundant components) + let key = path.normalize(p); + + // Windows file systems are case-insensitive, so we lowercase the key for + // deduplication + if (os.platform() === 'win32') { + key = key.toLowerCase(); + } + + if (!uniquePathsMap.has(key)) { + uniquePathsMap.set(key, p); + } + } + + return Array.from(uniquePathsMap.values()); +} export { createSandboxManager } from './sandboxManagerFactory.js'; diff --git a/packages/core/src/services/sandboxManagerFactory.ts b/packages/core/src/services/sandboxManagerFactory.ts index fffc366da9..410f5e07dc 100644 --- a/packages/core/src/services/sandboxManagerFactory.ts +++ b/packages/core/src/services/sandboxManagerFactory.ts @@ -28,7 +28,7 @@ export function createSandboxManager( isWindows && (sandbox?.enabled || sandbox?.command === 'windows-native') ) { - return new WindowsSandboxManager(); + return new WindowsSandboxManager({ workspace }); } if (sandbox?.enabled) { diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index e96cf7e037..98396fa4ee 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -437,7 +437,7 @@ export class ShellExecutionService { args: spawnArgs, env: baseEnv, cwd, - config: { + policy: { ...shellExecutionConfig, ...(shellExecutionConfig.sandboxConfig || {}), sanitizationConfig, diff --git a/packages/core/src/services/windowsSandboxManager.test.ts b/packages/core/src/services/windowsSandboxManager.test.ts index 6bec183410..966deefe6b 100644 --- a/packages/core/src/services/windowsSandboxManager.test.ts +++ b/packages/core/src/services/windowsSandboxManager.test.ts @@ -4,12 +4,28 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; import { WindowsSandboxManager } from './windowsSandboxManager.js'; import type { SandboxRequest } from './sandboxManager.js'; +import { spawnAsync } from '../utils/shell-utils.js'; + +vi.mock('../utils/shell-utils.js', () => ({ + spawnAsync: vi.fn(), +})); describe('WindowsSandboxManager', () => { - const manager = new WindowsSandboxManager('win32'); + let manager: WindowsSandboxManager; + + beforeEach(() => { + vi.spyOn(os, 'platform').mockReturnValue('win32'); + manager = new WindowsSandboxManager({ workspace: '/test/workspace' }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); it('should prepare a GeminiSandbox.exe command', async () => { const req: SandboxRequest = { @@ -17,7 +33,7 @@ describe('WindowsSandboxManager', () => { args: ['/groups'], cwd: '/test/cwd', env: { TEST_VAR: 'test_value' }, - config: { + policy: { networkAccess: false, }, }; @@ -34,7 +50,7 @@ describe('WindowsSandboxManager', () => { args: [], cwd: '/test/cwd', env: {}, - config: { + policy: { networkAccess: true, }, }; @@ -52,7 +68,7 @@ describe('WindowsSandboxManager', () => { API_KEY: 'secret', PATH: '/usr/bin', }, - config: { + policy: { sanitizationConfig: { allowedEnvironmentVariables: ['PATH'], blockedEnvironmentVariables: ['API_KEY'], @@ -65,4 +81,30 @@ describe('WindowsSandboxManager', () => { expect(result.env['PATH']).toBe('/usr/bin'); expect(result.env['API_KEY']).toBeUndefined(); }); + + it('should grant Low Integrity access to the workspace and allowed paths', async () => { + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: '/test/cwd', + env: {}, + policy: { + allowedPaths: ['/test/allowed1'], + }, + }; + + await manager.prepareCommand(req); + + expect(spawnAsync).toHaveBeenCalledWith('icacls', [ + path.resolve('/test/workspace'), + '/setintegritylevel', + 'Low', + ]); + + expect(spawnAsync).toHaveBeenCalledWith('icacls', [ + path.resolve('/test/allowed1'), + '/setintegritylevel', + 'Low', + ]); + }); }); diff --git a/packages/core/src/services/windowsSandboxManager.ts b/packages/core/src/services/windowsSandboxManager.ts index dc39b9ee67..347cb19395 100644 --- a/packages/core/src/services/windowsSandboxManager.ts +++ b/packages/core/src/services/windowsSandboxManager.ts @@ -6,15 +6,18 @@ import fs from 'node:fs'; import path from 'node:path'; +import os from 'node:os'; import { fileURLToPath } from 'node:url'; -import type { - SandboxManager, - SandboxRequest, - SandboxedCommand, +import { + type SandboxManager, + type SandboxRequest, + type SandboxedCommand, + type GlobalSandboxOptions, + sanitizePaths, } from './sandboxManager.js'; import { sanitizeEnvironment, - type EnvironmentSanitizationConfig, + getSecureSanitizationConfig, } from './environmentSanitization.js'; import { debugLogger } from '../utils/debugLogger.js'; import { spawnAsync } from '../utils/shell-utils.js'; @@ -29,18 +32,16 @@ const __dirname = path.dirname(__filename); */ export class WindowsSandboxManager implements SandboxManager { private readonly helperPath: string; - private readonly platform: string; private initialized = false; private readonly lowIntegrityCache = new Set(); - constructor(platform: string = process.platform) { - this.platform = platform; + constructor(private readonly options: GlobalSandboxOptions) { this.helperPath = path.resolve(__dirname, 'scripts', 'GeminiSandbox.exe'); } private async ensureInitialized(): Promise { if (this.initialized) return; - if (this.platform !== 'win32') { + if (os.platform() !== 'win32') { this.initialized = true; return; } @@ -145,36 +146,31 @@ export class WindowsSandboxManager implements SandboxManager { async prepareCommand(req: SandboxRequest): Promise { await this.ensureInitialized(); - const sanitizationConfig: EnvironmentSanitizationConfig = { - allowedEnvironmentVariables: - req.config?.sanitizationConfig?.allowedEnvironmentVariables ?? [], - blockedEnvironmentVariables: - req.config?.sanitizationConfig?.blockedEnvironmentVariables ?? [], - enableEnvironmentVariableRedaction: - req.config?.sanitizationConfig?.enableEnvironmentVariableRedaction ?? - true, - }; + const sanitizationConfig = getSecureSanitizationConfig( + req.policy?.sanitizationConfig, + ); const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); // 1. Handle filesystem permissions for Low Integrity - // Grant "Low Mandatory Level" write access to the CWD. - await this.grantLowIntegrityAccess(req.cwd); + // Grant "Low Mandatory Level" write access to the workspace. + await this.grantLowIntegrityAccess(this.options.workspace); // Grant "Low Mandatory Level" read access to allowedPaths. - if (req.config?.allowedPaths) { - for (const allowedPath of req.config.allowedPaths) { - await this.grantLowIntegrityAccess(allowedPath); - } + const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || []; + for (const allowedPath of allowedPaths) { + await this.grantLowIntegrityAccess(allowedPath); } + // TODO: handle forbidden paths + // 2. Construct the helper command // GeminiSandbox.exe [args...] const program = this.helperPath; // If the command starts with __, it's an internal command for the sandbox helper itself. const args = [ - req.config?.networkAccess ? '1' : '0', + req.policy?.networkAccess ? '1' : '0', req.cwd, req.command, ...req.args, @@ -191,7 +187,7 @@ export class WindowsSandboxManager implements SandboxManager { * Grants "Low Mandatory Level" access to a path using icacls. */ private async grantLowIntegrityAccess(targetPath: string): Promise { - if (this.platform !== 'win32') { + if (os.platform() !== 'win32') { return; } diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index 81b43abf50..933ca84817 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -119,8 +119,10 @@ describe('getCommandRoots', () => { expect(getCommandRoots('ls -l')).toEqual(['ls']); }); - it('should handle paths and return the binary name', () => { - expect(getCommandRoots('/usr/local/bin/node script.js')).toEqual(['node']); + it('should handle paths and return the full path', () => { + expect(getCommandRoots('/usr/local/bin/node script.js')).toEqual([ + '/usr/local/bin/node', + ]); }); it('should return an empty array for an empty string', () => { diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 89f50a9ce7..d2b28a348c 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -264,11 +264,7 @@ function normalizeCommandName(raw: string): string { return raw.slice(1, -1); } } - const trimmed = raw.trim(); - if (!trimmed) { - return trimmed; - } - return trimmed.split(/[\\/]/).pop() ?? trimmed; + return raw.trim(); } function extractNameFromNode(node: Node): string | null { diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index f836d5985e..90cdc03937 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -2673,8 +2673,8 @@ "enableAgents": { "title": "Enable Agents", "description": "Enable local and remote subagents.", - "markdownDescription": "Enable local and remote subagents.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`", - "default": true, + "markdownDescription": "Enable local and remote subagents.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, "type": "boolean" }, "worktrees": { diff --git a/scripts/changed_prompt.js b/scripts/changed_prompt.js index 0ad0e365f7..22563810e4 100644 --- a/scripts/changed_prompt.js +++ b/scripts/changed_prompt.js @@ -5,14 +5,26 @@ */ import { execSync } from 'node:child_process'; -const EVALS_FILE_PREFIXES = [ +const CORE_STEERING_PATHS = [ 'packages/core/src/prompts/', 'packages/core/src/tools/', - 'evals/', +]; + +const TEST_PATHS = ['evals/']; + +const STEERING_SIGNATURES = [ + 'LocalAgentDefinition', + 'LocalInvocation', + 'ToolDefinition', + 'inputSchema', + "kind: 'local'", ]; function main() { const targetBranch = process.env.GITHUB_BASE_REF || 'main'; + const verbose = process.argv.includes('--verbose'); + const steeringOnly = process.argv.includes('--steering-only'); + try { const remoteUrl = process.env.GITHUB_REPOSITORY ? `https://github.com/${process.env.GITHUB_REPOSITORY}.git` @@ -30,18 +42,60 @@ function main() { .split('\n') .filter(Boolean); - const shouldRun = changedFiles.some((file) => - EVALS_FILE_PREFIXES.some((prefix) => file.startsWith(prefix)), - ); + let detected = false; + const reasons = []; - console.log(shouldRun ? 'true' : 'false'); + // 1. Path-based detection + for (const file of changedFiles) { + if (CORE_STEERING_PATHS.some((prefix) => file.startsWith(prefix))) { + detected = true; + reasons.push(`Matched core steering path: ${file}`); + if (!verbose) break; + } + if ( + !steeringOnly && + TEST_PATHS.some((prefix) => file.startsWith(prefix)) + ) { + detected = true; + reasons.push(`Matched test path: ${file}`); + if (!verbose) break; + } + } + + // 2. Signature-based detection (only in packages/core/src/ and only if not already detected or if verbose) + if (!detected || verbose) { + const coreChanges = changedFiles.filter((f) => + f.startsWith('packages/core/src/'), + ); + if (coreChanges.length > 0) { + // Get the actual diff content for core files + const diff = execSync( + `git diff -U0 FETCH_HEAD...HEAD -- packages/core/src/`, + { encoding: 'utf-8' }, + ); + for (const sig of STEERING_SIGNATURES) { + if (diff.includes(sig)) { + detected = true; + reasons.push(`Matched steering signature in core: ${sig}`); + if (!verbose) break; + } + } + } + } + + if (verbose && reasons.length > 0) { + process.stderr.write('Detection reasons:\n'); + reasons.forEach((r) => process.stderr.write(` - ${r}\n`)); + } + + process.stdout.write(detected ? 'true' : 'false'); } catch (error) { - // If anything fails (e.g., no git history), run evals to be safe - console.warn( - 'Warning: Failed to determine if evals should run. Defaulting to true.', + // If anything fails (e.g., no git history), run evals/guidance to be safe + process.stderr.write( + 'Warning: Failed to determine if changes occurred. Defaulting to true.\n', ); - console.error(error); - console.log('true'); + process.stderr.write(String(error) + '\n'); + process.stdout.write('true'); } }