Compare commits

..

1 Commits

Author SHA1 Message Date
Sehoon Shon 12d0bd1da6 fix(core): disable retries for code assist oauth and server requests
Fixes #20557
2026-02-27 08:49:43 -05:00
78 changed files with 743 additions and 1474 deletions
+11 -12
View File
@@ -31,7 +31,6 @@ jobs:
name: 'Merge Queue Skipper'
permissions: 'read-all'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
outputs:
skip: '${{ steps.merge-queue-e2e-skipper.outputs.skip-check }}'
steps:
@@ -43,7 +42,7 @@ jobs:
download_repo_name:
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run')"
if: "${{github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run'}}"
outputs:
repo_name: '${{ steps.output-repo-name.outputs.repo_name }}'
head_sha: '${{ steps.output-repo-name.outputs.head_sha }}'
@@ -92,7 +91,7 @@ jobs:
name: 'Parse run context'
runs-on: 'gemini-cli-ubuntu-16-core'
needs: 'download_repo_name'
if: "github.repository == 'google-gemini/gemini-cli' && always()"
if: 'always()'
outputs:
repository: '${{ steps.set_context.outputs.REPO }}'
sha: '${{ steps.set_context.outputs.SHA }}'
@@ -112,11 +111,11 @@ jobs:
permissions: 'write-all'
needs:
- 'parse_run_context'
if: "github.repository == 'google-gemini/gemini-cli' && always()"
if: 'always()'
steps:
- name: 'Set pending status'
uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master
if: "github.repository == 'google-gemini/gemini-cli' && always()"
if: 'always()'
with:
allowForks: 'true'
repo: '${{ github.repository }}'
@@ -132,7 +131,7 @@ jobs:
- 'parse_run_context'
runs-on: 'gemini-cli-ubuntu-16-core'
if: |
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
strategy:
fail-fast: false
matrix:
@@ -185,7 +184,7 @@ jobs:
- 'parse_run_context'
runs-on: 'macos-latest'
if: |
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -223,7 +222,7 @@ jobs:
- 'merge_queue_skipper'
- 'parse_run_context'
if: |
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
runs-on: 'gemini-cli-windows-16-core'
steps:
- name: 'Checkout'
@@ -283,7 +282,7 @@ jobs:
- 'parse_run_context'
runs-on: 'gemini-cli-ubuntu-16-core'
if: |
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -310,7 +309,7 @@ jobs:
e2e:
name: 'E2E'
if: |
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
needs:
- 'e2e_linux'
- 'e2e_mac'
@@ -338,14 +337,14 @@ jobs:
set_workflow_status:
runs-on: 'gemini-cli-ubuntu-16-core'
permissions: 'write-all'
if: "github.repository == 'google-gemini/gemini-cli' && always()"
if: 'always()'
needs:
- 'parse_run_context'
- 'e2e'
steps:
- name: 'Set workflow status'
uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master
if: "github.repository == 'google-gemini/gemini-cli' && always()"
if: 'always()'
with:
allowForks: 'true'
repo: '${{ github.repository }}'
+7 -9
View File
@@ -37,7 +37,6 @@ jobs:
permissions: 'read-all'
name: 'Merge Queue Skipper'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
outputs:
skip: '${{ steps.merge-queue-ci-skipper.outputs.skip-check }}'
steps:
@@ -50,7 +49,7 @@ jobs:
name: 'Lint'
runs-on: 'gemini-cli-ubuntu-16-core'
needs: 'merge_queue_skipper'
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
env:
GEMINI_LINT_TEMP_DIR: '${{ github.workspace }}/.gemini-linters'
steps:
@@ -117,7 +116,6 @@ jobs:
link_checker:
name: 'Link Checker'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
@@ -131,7 +129,7 @@ jobs:
runs-on: 'gemini-cli-ubuntu-16-core'
needs:
- 'merge_queue_skipper'
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
permissions:
contents: 'read'
checks: 'write'
@@ -218,7 +216,7 @@ jobs:
runs-on: 'macos-latest'
needs:
- 'merge_queue_skipper'
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
permissions:
contents: 'read'
checks: 'write'
@@ -313,7 +311,7 @@ jobs:
name: 'CodeQL'
runs-on: 'gemini-cli-ubuntu-16-core'
needs: 'merge_queue_skipper'
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
permissions:
actions: 'read'
contents: 'read'
@@ -336,7 +334,7 @@ jobs:
bundle_size:
name: 'Check Bundle Size'
needs: 'merge_queue_skipper'
if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'"
if: "${{github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'}}"
runs-on: 'gemini-cli-ubuntu-16-core'
permissions:
contents: 'read' # For checkout
@@ -361,7 +359,7 @@ jobs:
name: 'Slow Test - Win - ${{ matrix.shard }}'
runs-on: 'gemini-cli-windows-16-core'
needs: 'merge_queue_skipper'
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
timeout-minutes: 60
strategy:
matrix:
@@ -453,7 +451,7 @@ jobs:
ci:
name: 'CI'
if: "github.repository == 'google-gemini/gemini-cli' && always()"
if: 'always()'
needs:
- 'lint'
- 'link_checker'
-3
View File
@@ -27,7 +27,6 @@ jobs:
deflake_e2e_linux:
name: 'E2E Test (Linux) - ${{ matrix.sandbox }}'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
strategy:
fail-fast: false
matrix:
@@ -78,7 +77,6 @@ jobs:
deflake_e2e_mac:
name: 'E2E Test (macOS)'
runs-on: 'macos-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -116,7 +114,6 @@ jobs:
deflake_e2e_windows:
name: 'Slow E2E - Win'
runs-on: 'gemini-cli-windows-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
+50
View File
@@ -0,0 +1,50 @@
name: 'Deploy GitHub Pages'
on:
push:
tags: 'v*'
workflow_dispatch:
permissions:
contents: 'read'
pages: 'write'
id-token: 'write'
# Allow only one concurrent deployment, skipping runs queued between the run
# in-progress and latest queued. However, do NOT cancel in-progress runs as we
# want to allow these production deployments to complete.
concurrency:
group: '${{ github.workflow }}'
cancel-in-progress: false
jobs:
build:
if: |-
${{ !contains(github.ref_name, 'nightly') }}
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
- name: 'Setup Pages'
uses: 'actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b' # ratchet:actions/configure-pages@v5
- name: 'Build with Jekyll'
uses: 'actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697' # ratchet:actions/jekyll-build-pages@v1
with:
source: './'
destination: './_site'
- name: 'Upload artifact'
uses: 'actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa' # ratchet:actions/upload-pages-artifact@v3
deploy:
environment:
name: 'github-pages'
url: '${{ steps.deployment.outputs.page_url }}'
runs-on: 'ubuntu-latest'
needs: 'build'
steps:
- name: 'Deploy to GitHub Pages'
id: 'deployment'
uses: 'actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e' # ratchet:actions/deploy-pages@v4
-1
View File
@@ -7,7 +7,6 @@ on:
- 'docs/**'
jobs:
trigger-rebuild:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
steps:
- name: 'Trigger rebuild'
+1 -2
View File
@@ -23,7 +23,6 @@ jobs:
evals:
name: 'Evals (USUALLY_PASSING) nightly run'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
strategy:
fail-fast: false
matrix:
@@ -86,7 +85,7 @@ jobs:
aggregate-results:
name: 'Aggregate Results'
needs: ['evals']
if: "github.repository == 'google-gemini/gemini-cli' && always()"
if: 'always()'
runs-on: 'gemini-cli-ubuntu-16-core'
steps:
- name: 'Checkout'
@@ -21,7 +21,6 @@ defaults:
jobs:
close-stale-issues:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
issues: 'write'
@@ -14,7 +14,7 @@ permissions:
jobs:
# Event-based: Quick reaction to new/edited issues in THIS repo
labeler:
if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'issues'"
if: "github.event_name == 'issues'"
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout'
@@ -36,7 +36,7 @@ jobs:
# Scheduled/Manual: Recursive sync across multiple repos
sync-maintainer-labels:
if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')"
if: "github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'"
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout'
@@ -9,7 +9,6 @@ on:
jobs:
labeler:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
issues: 'write'
@@ -32,7 +32,6 @@ on:
jobs:
change-tags:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
environment: "${{ github.event.inputs.environment || 'prod' }}"
permissions:
-1
View File
@@ -47,7 +47,6 @@ on:
jobs:
release:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
environment: "${{ github.event.inputs.environment || 'prod' }}"
permissions:
-1
View File
@@ -22,7 +22,6 @@ on:
jobs:
generate-release-notes:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
contents: 'write'
-1
View File
@@ -42,7 +42,6 @@ on:
jobs:
change-tags:
if: "github.repository == 'google-gemini/gemini-cli'"
environment: "${{ github.event.inputs.environment || 'prod' }}"
runs-on: 'ubuntu-latest'
permissions:
-1
View File
@@ -16,7 +16,6 @@ on:
jobs:
build:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
contents: 'read'
-1
View File
@@ -20,7 +20,6 @@ on:
jobs:
smoke-test:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
contents: 'write'
-2
View File
@@ -15,7 +15,6 @@ on:
jobs:
save_repo_name:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'gemini-cli-ubuntu-16-core'
steps:
- name: 'Save Repo name'
@@ -32,7 +31,6 @@ jobs:
path: 'pr/'
trigger_e2e:
name: 'Trigger e2e'
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'gemini-cli-ubuntu-16-core'
steps:
- id: 'trigger-e2e'
-1
View File
@@ -28,7 +28,6 @@ on:
jobs:
verify-release:
if: "github.repository == 'google-gemini/gemini-cli'"
environment: "${{ github.event.inputs.environment || 'prod' }}"
strategy:
fail-fast: false
+1 -1
View File
@@ -8,7 +8,7 @@ import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('validation_fidelity', () => {
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: 'should perform exhaustive validation autonomously when guided by system instructions',
files: {
'src/types.ts': `
-1
View File
@@ -843,7 +843,6 @@ export async function loadCliConfig(
interactive,
trustedFolder,
useBackgroundColor: settings.ui?.useBackgroundColor,
useAlternateBuffer: settings.ui?.useAlternateBuffer,
useRipgrep: settings.tools?.useRipgrep,
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
+1 -32
View File
@@ -12,8 +12,6 @@ import {
resolveWorkspacePolicyState,
autoAcceptWorkspacePolicies,
setAutoAcceptWorkspacePolicies,
disableWorkspacePolicies,
setDisableWorkspacePolicies,
} from './policy.js';
import { writeToStderr } from '@google/gemini-cli-core';
@@ -47,9 +45,6 @@ describe('resolveWorkspacePolicyState', () => {
fs.mkdirSync(workspaceDir);
policiesDir = path.join(workspaceDir, '.gemini', 'policies');
// Enable policies for these tests to verify loading logic
setDisableWorkspacePolicies(false);
vi.clearAllMocks();
});
@@ -72,13 +67,6 @@ describe('resolveWorkspacePolicyState', () => {
});
});
it('should have disableWorkspacePolicies set to true by default', () => {
// We explicitly set it to false in beforeEach for other tests,
// so here we test that setting it to true works.
setDisableWorkspacePolicies(true);
expect(disableWorkspacePolicies).toBe(true);
});
it('should return policy directory if integrity matches', async () => {
// Set up policies directory with a file
fs.mkdirSync(policiesDir, { recursive: true });
@@ -200,26 +188,7 @@ describe('resolveWorkspacePolicyState', () => {
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
});
it('should return empty state if disableWorkspacePolicies is true even if folder is trusted', async () => {
setDisableWorkspacePolicies(true);
// Set up policies directory with a file
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
});
expect(result).toEqual({
workspacePoliciesDir: undefined,
policyUpdateConfirmationRequest: undefined,
});
});
it('should return empty state if cwd is a symlink to the home directory', async () => {
it('should not return workspace policies if cwd is a symlink to the home directory', async () => {
const policiesDir = path.join(tempDir, '.gemini', 'policies');
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
+1 -15
View File
@@ -35,20 +35,6 @@ export function setAutoAcceptWorkspacePolicies(value: boolean) {
autoAcceptWorkspacePolicies = value;
}
/**
* Temporary flag to disable workspace level policies altogether.
* Exported as 'let' to allow monkey patching in tests via the setter.
*/
export let disableWorkspacePolicies = true;
/**
* Sets the disableWorkspacePolicies flag.
* Used primarily for testing purposes.
*/
export function setDisableWorkspacePolicies(value: boolean) {
disableWorkspacePolicies = value;
}
export async function createPolicyEngineConfig(
settings: Settings,
approvalMode: ApprovalMode,
@@ -95,7 +81,7 @@ export async function resolveWorkspacePolicyState(options: {
| PolicyUpdateConfirmationRequest
| undefined;
if (trustedFolder && !disableWorkspacePolicies) {
if (trustedFolder) {
const storage = new Storage(cwd);
// If we are in the home directory (or rather, our target Gemini dir is the global one),
@@ -54,7 +54,6 @@ describe('Workspace-Level Policy CLI Integration', () => {
beforeEach(() => {
vi.clearAllMocks();
Policy.setDisableWorkspacePolicies(false);
// Default to MATCH for existing tests
mockCheckIntegrity.mockResolvedValue({
status: 'match',
-1
View File
@@ -1182,7 +1182,6 @@ describe('startInteractiveUI', () => {
getProjectRoot: () => '/root',
getScreenReader: () => false,
getDebugMode: () => false,
getUseAlternateBuffer: () => true,
});
const mockSettings = {
merged: {
+3 -3
View File
@@ -102,8 +102,8 @@ import { loadSandboxConfig } from './config/sandboxConfig.js';
import { deleteSession, listSessions } from './utils/sessions.js';
import { createPolicyUpdater } from './config/policy.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
@@ -196,7 +196,7 @@ export async function startInteractiveUI(
// and the Ink alternate buffer mode requires line wrapping harmful to
// screen readers.
const useAlternateBuffer = shouldEnterAlternateScreen(
isAlternateBufferEnabled(config),
isAlternateBufferEnabled(settings),
config.getScreenReader(),
);
const mouseEventsEnabled = useAlternateBuffer;
@@ -678,7 +678,7 @@ export async function main() {
let input = config.getQuestion();
const useAlternateBuffer = shouldEnterAlternateScreen(
isAlternateBufferEnabled(config),
isAlternateBufferEnabled(settings),
config.getScreenReader(),
);
const rawStartupWarnings = await getStartupWarnings();
@@ -156,7 +156,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getExperiments: vi.fn().mockReturnValue(undefined),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
getUseAlternateBuffer: vi.fn().mockReturnValue(false),
...overrides,
}) as unknown as Config;
+2 -17
View File
@@ -703,21 +703,6 @@ export const renderWithProviders = (
});
}
// Wrap config in a Proxy so useAlternateBuffer hook (which reads from Config) gets the correct value,
// without replacing the entire config object and its other values.
let finalConfig = config;
if (useAlternateBuffer !== undefined) {
finalConfig = new Proxy(config, {
get(target, prop, receiver) {
if (prop === 'getUseAlternateBuffer') {
return () => useAlternateBuffer;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Reflect.get(target, prop, receiver);
},
});
}
const mainAreaWidth = terminalWidth;
const finalUiState = {
@@ -746,7 +731,7 @@ export const renderWithProviders = (
const renderResult = render(
<AppContext.Provider value={appState}>
<ConfigContext.Provider value={finalConfig}>
<ConfigContext.Provider value={config}>
<SettingsContext.Provider value={finalSettings}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider settings={finalSettings}>
@@ -758,7 +743,7 @@ export const renderWithProviders = (
<UIActionsContext.Provider value={finalUIActions}>
<OverflowProvider>
<ToolActionsProvider
config={finalConfig}
config={config}
toolCalls={allToolCalls}
>
<AskUserActionsProvider
@@ -2675,10 +2675,6 @@ describe('AppContainer State Management', () => {
isAlternateMode = false,
childHandler?: Mock,
) => {
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(
isAlternateMode,
);
// Update settings for this test run
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const testSettings = {
@@ -3368,8 +3364,6 @@ describe('AppContainer State Management', () => {
);
vi.mocked(checkPermissions).mockResolvedValue([]);
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
let unmount: () => void;
await act(async () => {
unmount = renderAppContainer({
@@ -3602,8 +3596,6 @@ describe('AppContainer State Management', () => {
},
} as unknown as LoadedSettings;
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({
+71 -21
View File
@@ -145,6 +145,7 @@ import { useSessionResume } from './hooks/useSessionResume.js';
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js';
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
import { useSettings } from './contexts/SettingsContext.js';
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
@@ -227,7 +228,7 @@ export const AppContainer = (props: AppContainerProps) => {
});
useMemoryMonitor(historyManager);
const isAlternateBuffer = config.getUseAlternateBuffer();
const isAlternateBuffer = useAlternateBuffer();
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
@@ -263,16 +264,14 @@ export const AppContainer = (props: AppContainerProps) => {
() => isWorkspaceTrusted(settings.merged).isTrusted,
);
const [queueErrorMessage, setQueueErrorMessage] = useTimedMessage<string>(
QUEUE_ERROR_DISPLAY_DURATION_MS,
const [queueErrorMessage, setQueueErrorMessage] = useState<string | null>(
null,
);
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [expandHintTrigger, triggerExpandHint] = useTimedMessage<boolean>(
EXPAND_HINT_DURATION_MS,
);
const showIsExpandableHint = Boolean(expandHintTrigger);
const [showIsExpandableHint, setShowIsExpandableHint] = useState(false);
const expandHintTimerRef = useRef<NodeJS.Timeout | null>(null);
const overflowState = useOverflowState();
const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0;
const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight;
@@ -285,15 +284,39 @@ export const AppContainer = (props: AppContainerProps) => {
* boolean dependency (hasOverflowState) to ensure the timer only resets on
* genuine state transitions, preventing it from infinitely resetting during
* active text streaming.
*
* In alternate buffer mode, we don't trigger the hint automatically on overflow
* to avoid noise, but the user can still trigger it manually with Ctrl+O.
*/
useEffect(() => {
if (hasOverflowState && !isAlternateBuffer) {
triggerExpandHint(true);
if (isAlternateBuffer) {
setShowIsExpandableHint(false);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
return;
}
}, [hasOverflowState, isAlternateBuffer, triggerExpandHint]);
if (hasOverflowState) {
setShowIsExpandableHint(true);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
expandHintTimerRef.current = setTimeout(() => {
setShowIsExpandableHint(false);
}, EXPAND_HINT_DURATION_MS);
}
}, [hasOverflowState, isAlternateBuffer, constrainHeight]);
/**
* Safe cleanup to ensure the expansion hint timer is cancelled when the
* component unmounts, preventing memory leaks.
*/
useEffect(
() => () => {
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
},
[],
);
const [defaultBannerText, setDefaultBannerText] = useState('');
const [warningBannerText, setWarningBannerText] = useState('');
@@ -544,7 +567,7 @@ export const AppContainer = (props: AppContainerProps) => {
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
useConsoleMessages();
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, config);
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings);
// Derive widths for InputPrompt using shared helper
const { inputWidth, suggestionsWidth } = useMemo(() => {
const { inputWidth, suggestionsWidth } =
@@ -1229,7 +1252,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
async (submittedValue: string) => {
reset();
// Explicitly hide the expansion hint and clear its x-second timer when a new turn begins.
triggerExpandHint(null);
setShowIsExpandableHint(false);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
if (!constrainHeight) {
setConstrainHeight(true);
if (!isAlternateBuffer) {
@@ -1301,14 +1327,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
refreshStatic,
reset,
handleHintSubmit,
triggerExpandHint,
],
);
const handleClearScreen = useCallback(() => {
reset();
// Explicitly hide the expansion hint and clear its x-second timer when clearing the screen.
triggerExpandHint(null);
setShowIsExpandableHint(false);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
historyManager.clearItems();
clearConsoleMessagesState();
refreshStatic();
@@ -1317,7 +1345,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
clearConsoleMessagesState,
refreshStatic,
reset,
triggerExpandHint,
setShowIsExpandableHint,
]);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
@@ -1604,6 +1632,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}, [ideNeedsRestart]);
useEffect(() => {
if (queueErrorMessage) {
const timer = setTimeout(() => {
setQueueErrorMessage(null);
}, QUEUE_ERROR_DISPLAY_DURATION_MS);
return () => clearTimeout(timer);
}
return undefined;
}, [queueErrorMessage, setQueueErrorMessage]);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
@@ -1709,7 +1748,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
setConstrainHeight(true);
if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
// If the user manually collapses the view, show the hint and reset the x-second timer.
triggerExpandHint(true);
setShowIsExpandableHint(true);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
expandHintTimerRef.current = setTimeout(() => {
setShowIsExpandableHint(false);
}, EXPAND_HINT_DURATION_MS);
}
if (!isAlternateBuffer) {
refreshStatic();
@@ -1758,7 +1803,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
) {
setConstrainHeight(false);
// If the user manually expands the view, show the hint and reset the x-second timer.
triggerExpandHint(true);
setShowIsExpandableHint(true);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
expandHintTimerRef.current = setTimeout(() => {
setShowIsExpandableHint(false);
}, EXPAND_HINT_DURATION_MS);
if (!isAlternateBuffer) {
refreshStatic();
}
@@ -1863,7 +1914,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
showTransientMessage,
settings.merged.general.devtools,
showErrorDetails,
triggerExpandHint,
],
);
@@ -6,8 +6,8 @@
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import { render } from '../../test-utils/render.js';
import { act, useEffect } from 'react';
import { Box, Text } from 'ink';
import { useEffect } from 'react';
import { Composer } from './Composer.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import {
@@ -34,7 +34,6 @@ import { StreamingState } from '../types.js';
import { TransientMessageType } from '../../utils/events.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import type { TextBuffer } from './shared/text-buffer.js';
const composerTestControls = vi.hoisted(() => ({
suggestionsVisible: false,
@@ -264,26 +263,16 @@ const renderComposer = async (
</ConfigContext.Provider>,
);
await result.waitUntilReady();
// Wait for shortcuts hint debounce if using fake timers
if (vi.isFakeTimers()) {
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
}
return result;
};
describe('Composer', () => {
beforeEach(() => {
vi.useFakeTimers();
composerTestControls.suggestionsVisible = false;
composerTestControls.isAlternateBuffer = false;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -402,7 +391,7 @@ describe('Composer', () => {
expect(output).not.toContain('ShortcutsHint');
});
it('renders LoadingIndicator with thought when loadingPhrases is off', async () => {
it('renders LoadingIndicator without thought when loadingPhrases is off', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: { subject: 'Hidden', description: 'Should not show' },
@@ -415,7 +404,7 @@ describe('Composer', () => {
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).toContain('LoadingIndicator: Hidden');
expect(output).not.toContain('Should not show');
});
it('does not render LoadingIndicator when waiting for confirmation', async () => {
@@ -820,28 +809,6 @@ describe('Composer', () => {
});
describe('Shortcuts Hint', () => {
it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => {
const { lastFrame } = await renderComposer(
createMockUIState({
buffer: { text: '' } as unknown as TextBuffer,
cleanUiDetailsVisible: false,
}),
);
expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint');
});
it('does not show shortcuts hint immediately when buffer has text', async () => {
const uiState = createMockUIState({
buffer: { text: 'hello' } as unknown as TextBuffer,
cleanUiDetailsVisible: false,
});
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('hides shortcuts hint when showShortcutsHint setting is false', async () => {
const uiState = createMockUIState();
const settings = createMockSettings({
@@ -890,27 +857,6 @@ describe('Composer', () => {
expect(lastFrame()).toContain('ShortcutsHint');
});
it('hides shortcuts hint while loading when full UI details are visible', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: true,
streamingState: StreamingState.Responding,
});
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('hides shortcuts hint when text is typed in buffer', async () => {
const uiState = createMockUIState({
buffer: { text: 'hello' } as unknown as TextBuffer,
});
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('hides shortcuts hint while loading in minimal mode', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
@@ -984,10 +930,9 @@ describe('Composer', () => {
streamingState: StreamingState.Idle,
});
const { lastFrame, unmount } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHelp');
unmount();
});
it('hides shortcuts help while streaming', async () => {
@@ -996,10 +941,9 @@ describe('Composer', () => {
streamingState: StreamingState.Responding,
});
const { lastFrame, unmount } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHelp');
unmount();
});
it('hides shortcuts help when action is required', async () => {
@@ -1012,10 +956,9 @@ describe('Composer', () => {
),
});
const { lastFrame, unmount } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHelp');
unmount();
});
});
+6 -23
View File
@@ -151,30 +151,11 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
: undefined,
);
const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions;
const isModelIdle = uiState.streamingState === StreamingState.Idle;
const isBufferEmpty = uiState.buffer.text.length === 0;
const canShowShortcutsHint =
isModelIdle && isBufferEmpty && !hasPendingActionRequired;
const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] =
useState(canShowShortcutsHint);
useEffect(() => {
if (!canShowShortcutsHint) {
setShowShortcutsHintDebounced(false);
return;
}
const timeout = setTimeout(() => {
setShowShortcutsHintDebounced(true);
}, 200);
return () => clearTimeout(timeout);
}, [canShowShortcutsHint]);
const showShortcutsHint =
settings.merged.ui.showShortcutsHint &&
!hideShortcutsHintForSuggestions &&
showShortcutsHintDebounced;
!hideMinimalModeHintWhileBusy &&
!hasPendingActionRequired;
const showMinimalModeBleedThrough =
!hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough);
const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator;
@@ -229,7 +210,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
inline
thought={
uiState.streamingState ===
StreamingState.WaitingForConfirmation
StreamingState.WaitingForConfirmation ||
settings.merged.ui.loadingPhrases === 'off'
? undefined
: uiState.thought
}
@@ -272,7 +254,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
inline
thought={
uiState.streamingState ===
StreamingState.WaitingForConfirmation
StreamingState.WaitingForConfirmation ||
settings.merged.ui.loadingPhrases === 'off'
? undefined
: uiState.thought
}
@@ -167,7 +167,6 @@ Implement a comprehensive authentication system with multiple providers.
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => options?.useAlternateBuffer ?? true,
} as unknown as import('@google/gemini-cli-core').Config,
},
);
@@ -444,7 +443,6 @@ Implement a comprehensive authentication system with multiple providers.
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
} as unknown as import('@google/gemini-cli-core').Config,
},
);
@@ -51,7 +51,6 @@ describe('ToolConfirmationQueue', () => {
storage: {
getPlansDir: () => '/mock/temp/plans',
},
getUseAlternateBuffer: () => false,
} as unknown as Config;
beforeEach(() => {
@@ -228,7 +227,7 @@ describe('ToolConfirmationQueue', () => {
// availableContentHeight = Math.max(9 - 6, 4) = 4
// MaxSizedBox in ToolConfirmationMessage will use 4
// It should show truncation message
await waitFor(() => expect(lastFrame()).toContain('49 hidden (Ctrl+O)'));
await waitFor(() => expect(lastFrame()).toContain('first 49 lines hidden'));
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -35,7 +35,7 @@ Footer
`;
exports[`Composer > Snapshots > matches snapshot while streaming 1`] = `
" LoadingIndicator: Thinking
" LoadingIndicator: Thinking ShortcutsHint
────────────────────────────────────────────────────────────────────────────────────────────────────
ApprovalModeIndicator
InputPrompt: Type your message or @path/to/file
@@ -74,7 +74,7 @@ Implementation Steps
6. Add LDAP provider support in src/auth/providers/LDAPProvider.ts
7. Create token refresh mechanism in src/auth/TokenManager.ts
8. Add multi-factor authentication in src/auth/MFAService.ts
... last 22 lines hidden (Ctrl+O to show) ...
... last 22 lines hidden ...
● 1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
@@ -112,7 +112,7 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini item 1`] = `
"✦ Example code block:
... 42 hidden (Ctrl+O) ...
... first 42 lines hidden ...
43 Line 43
44 Line 44
45 Line 45
@@ -126,7 +126,7 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini_content item 1`] = `
" Example code block:
... 42 hidden (Ctrl+O) ...
... first 42 lines hidden ...
43 Line 43
44 Line 44
45 Line 45
@@ -49,7 +49,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
│ ... first 11 lines hidden (Ctrl+O to show) ...
│ ... first 11 lines hidden ...
│ Line 12 │
│ Line 13 │
│ Line 14 │
@@ -6,7 +6,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
│ │
│ ? replace edit file │
│ │
│ ... 49 hidden (Ctrl+O) ...
│ ... first 49 lines hidden ...
│ 50 line │
│ Apply this change? │
│ │
@@ -96,7 +96,7 @@ exports[`ToolConfirmationQueue > renders expansion hint when content is long and
│ │
│ ? replace edit file │
│ │
│ ... 49 hidden (Ctrl+O) ...
│ ... first 49 lines hidden ...
│ 50 line │
│ Apply this change? │
│ │
@@ -106,7 +106,7 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
);
// Verify truncation is occurring (standard mode uses MaxSizedBox)
await waitFor(() => expect(lastFrame()).toContain('hidden (Ctrl+O'));
await waitFor(() => expect(lastFrame()).toContain('hidden ...'));
// In Standard mode, ToolGroupMessage calculates hasOverflow correctly now.
// While Standard mode doesn't render the inline hint (ShowMoreLines returns null),
@@ -10,7 +10,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 30 and height 6 1`] = `
"... 10 hidden (Ctrl+O) ...
"... first 10 lines hidden ...
'test';
21 + const anotherNew =
'test';
@@ -20,7 +20,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = `
"... first 4 lines hidden (Ctrl+O to show) ...
"... first 4 lines hidden ...
════════════════════════════════════════════════════════════════════════════════
20 console.log('second hunk');
21 - const anotherOld = 'test';
@@ -103,7 +103,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 30 and height 6 1`] = `
"... 10 hidden (Ctrl+O) ...
"... first 10 lines hidden ...
'test';
21 + const anotherNew =
'test';
@@ -113,7 +113,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = `
"... first 4 lines hidden (Ctrl+O to show) ...
"... first 4 lines hidden ...
════════════════════════════════════════════════════════════════════════════════
20 console.log('second hunk');
21 - const anotherOld = 'test';
@@ -37,7 +37,7 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp
`;
exports[`ToolResultDisplay > truncates very long string results 1`] = `
"... 248 hidden (Ctrl+O) ...
"... first 248 lines hidden ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
@@ -41,9 +41,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 2 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toContain('... first 2 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -61,9 +59,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'... last 2 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toContain('... last 2 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -81,9 +77,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 2 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toContain('... first 2 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -99,9 +93,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 1 line hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toContain('... first 1 line hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -119,9 +111,7 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 7 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toContain('... first 7 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -207,9 +197,7 @@ describe('<MaxSizedBox />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(
'... first 21 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toContain('... first 21 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -230,9 +218,7 @@ describe('<MaxSizedBox />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(
'... last 21 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toContain('... last 21 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -261,9 +247,7 @@ describe('<MaxSizedBox />', () => {
const lastLine = lines[lines.length - 1];
// The last line should only contain the hidden indicator, no leaked content
expect(lastLine).toMatch(
/^\.\.\. last \d+ lines? hidden \(Ctrl\+O to show\) \.\.\.$/,
);
expect(lastLine).toMatch(/^\.\.\. last \d+ lines? hidden \.\.\.$/);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -9,9 +9,6 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
import { isNarrowWidth } from '../../utils/isNarrowWidth.js';
import { Command } from '../../../config/keyBindings.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
/**
* Minimum height for the MaxSizedBox component.
@@ -87,9 +84,6 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount;
const isNarrow = maxWidth !== undefined && isNarrowWidth(maxWidth);
const showMoreKey = formatCommand(Command.SHOW_MORE_LINES);
useEffect(() => {
if (totalHiddenLines > 0) {
addOverflowingId?.(id);
@@ -122,9 +116,8 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
>
{totalHiddenLines > 0 && overflowDirection === 'top' && (
<Text color={theme.text.secondary} wrap="truncate">
{isNarrow
? `... ${totalHiddenLines} hidden (${showMoreKey}) ...`
: `... first ${totalHiddenLines} line${totalHiddenLines === 1 ? '' : 's'} hidden (${showMoreKey} to show) ...`}
... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
hidden ...
</Text>
)}
<Box
@@ -144,9 +137,8 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
</Box>
{totalHiddenLines > 0 && overflowDirection === 'bottom' && (
<Text color={theme.text.secondary} wrap="truncate">
{isNarrow
? `... ${totalHiddenLines} hidden (${showMoreKey}) ...`
: `... last ${totalHiddenLines} line${totalHiddenLines === 1 ? '' : 's'} hidden (${showMoreKey} to show) ...`}
... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
hidden ...
</Text>
)}
</Box>
@@ -6,11 +6,20 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { Scrollable } from './Scrollable.js';
import { Text, Box } from 'ink';
import { Text } from 'ink';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as ScrollProviderModule from '../../contexts/ScrollProvider.js';
import { act } from 'react';
import { waitFor } from '../../../test-utils/async.js';
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
getInnerHeight: vi.fn(() => 5),
getScrollHeight: vi.fn(() => 10),
getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 5 })),
};
});
vi.mock('../../hooks/useAnimatedScrollbar.js', () => ({
useAnimatedScrollbar: (
@@ -120,26 +129,20 @@ describe('<Scrollable />', () => {
</Scrollable>,
);
await waitUntilReady2();
await waitFor(() => {
expect(capturedEntry?.getScrollState().scrollTop).toBe(5);
});
expect(capturedEntry.getScrollState().scrollTop).toBe(5);
// Call scrollBy multiple times (upwards) in the same tick
await act(async () => {
capturedEntry?.scrollBy(-1);
capturedEntry?.scrollBy(-1);
capturedEntry!.scrollBy(-1);
capturedEntry!.scrollBy(-1);
});
// Should have moved up by 2 (5 -> 3)
await waitFor(() => {
expect(capturedEntry?.getScrollState().scrollTop).toBe(3);
});
expect(capturedEntry.getScrollState().scrollTop).toBe(3);
await act(async () => {
capturedEntry?.scrollBy(-2);
});
await waitFor(() => {
expect(capturedEntry?.getScrollState().scrollTop).toBe(1);
capturedEntry!.scrollBy(-2);
});
expect(capturedEntry.getScrollState().scrollTop).toBe(1);
unmount2();
});
@@ -188,6 +191,10 @@ describe('<Scrollable />', () => {
keySequence,
expectedScrollTop,
}) => {
// Dynamically import ink to mock getScrollHeight
const ink = await import('ink');
vi.mocked(ink.getScrollHeight).mockReturnValue(scrollHeight);
let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined;
vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation(
async (entry, isActive) => {
@@ -199,9 +206,7 @@ describe('<Scrollable />', () => {
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<Scrollable hasFocus={true} height={5}>
<Box height={scrollHeight}>
<Text>Content</Text>
</Box>
<Text>Content</Text>
</Scrollable>,
);
await waitUntilReady();
@@ -4,9 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
import { Box, ResizeObserver, type DOMElement } from 'ink';
import React, {
useState,
useEffect,
useRef,
useLayoutEffect,
useCallback,
useMemo,
} from 'react';
import { Box, getInnerHeight, getScrollHeight, type DOMElement } from 'ink';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
@@ -35,101 +41,62 @@ export const Scrollable: React.FC<ScrollableProps> = ({
flexGrow,
}) => {
const [scrollTop, setScrollTop] = useState(0);
const viewportRef = useRef<DOMElement | null>(null);
const contentRef = useRef<DOMElement | null>(null);
const ref = useRef<DOMElement>(null);
const [size, setSize] = useState({
innerHeight: typeof height === 'number' ? height : 0,
innerHeight: 0,
scrollHeight: 0,
});
const sizeRef = useRef(size);
const scrollTopRef = useRef(scrollTop);
useLayoutEffect(() => {
useEffect(() => {
sizeRef.current = size;
}, [size]);
const childrenCountRef = useRef(0);
// This effect needs to run on every render to correctly measure the container
// and scroll to the bottom if new children are added.
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
scrollTopRef.current = scrollTop;
}, [scrollTop]);
const viewportObserverRef = useRef<ResizeObserver | null>(null);
const contentObserverRef = useRef<ResizeObserver | null>(null);
const viewportRefCallback = useCallback((node: DOMElement | null) => {
viewportObserverRef.current?.disconnect();
viewportRef.current = node;
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const innerHeight = Math.round(entry.contentRect.height);
setSize((prev) => {
const scrollHeight = prev.scrollHeight;
const isAtBottom =
scrollHeight > prev.innerHeight &&
scrollTopRef.current >= scrollHeight - prev.innerHeight - 1;
if (isAtBottom) {
setScrollTop(Number.MAX_SAFE_INTEGER);
}
return { ...prev, innerHeight };
});
}
});
observer.observe(node);
viewportObserverRef.current = observer;
if (!ref.current) {
return;
}
}, []);
const innerHeight = Math.round(getInnerHeight(ref.current));
const scrollHeight = Math.round(getScrollHeight(ref.current));
const contentRefCallback = useCallback(
(node: DOMElement | null) => {
contentObserverRef.current?.disconnect();
contentRef.current = node;
const isAtBottom =
scrollHeight > innerHeight && scrollTop >= scrollHeight - innerHeight - 1;
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const scrollHeight = Math.round(entry.contentRect.height);
setSize((prev) => {
const innerHeight = prev.innerHeight;
const isAtBottom =
prev.scrollHeight > innerHeight &&
scrollTopRef.current >= prev.scrollHeight - innerHeight - 1;
if (
isAtBottom ||
(scrollToBottom && scrollHeight > prev.scrollHeight)
) {
setScrollTop(Number.MAX_SAFE_INTEGER);
}
return { ...prev, scrollHeight };
});
}
});
observer.observe(node);
contentObserverRef.current = observer;
if (
size.innerHeight !== innerHeight ||
size.scrollHeight !== scrollHeight
) {
setSize({ innerHeight, scrollHeight });
if (isAtBottom) {
setScrollTop(Math.max(0, scrollHeight - innerHeight));
}
},
[scrollToBottom],
);
}
const childCountCurrent = React.Children.count(children);
if (scrollToBottom && childrenCountRef.current !== childCountCurrent) {
setScrollTop(Math.max(0, scrollHeight - innerHeight));
}
childrenCountRef.current = childCountCurrent;
});
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
const scrollBy = useCallback(
(delta: number) => {
const { scrollHeight, innerHeight } = sizeRef.current;
const maxScroll = Math.max(0, scrollHeight - innerHeight);
const current = Math.min(getScrollTop(), maxScroll);
let next = Math.max(0, current + delta);
if (next >= maxScroll) {
next = Number.MAX_SAFE_INTEGER;
}
const current = getScrollTop();
const next = Math.min(
Math.max(0, current + delta),
Math.max(0, scrollHeight - innerHeight),
);
setPendingScrollTop(next);
setScrollTop(next);
},
[getScrollTop, setPendingScrollTop],
[sizeRef, getScrollTop, setPendingScrollTop],
);
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
@@ -140,11 +107,10 @@ export const Scrollable: React.FC<ScrollableProps> = ({
const { scrollHeight, innerHeight } = sizeRef.current;
const scrollTop = getScrollTop();
const maxScroll = Math.max(0, scrollHeight - innerHeight);
const actualScrollTop = Math.min(scrollTop, maxScroll);
// Only capture scroll-up events if there's room;
// otherwise allow events to bubble.
if (actualScrollTop > 0) {
if (scrollTop > 0) {
if (keyMatchers[Command.PAGE_UP](key)) {
scrollByWithAnimation(-innerHeight);
return true;
@@ -157,7 +123,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
// Only capture scroll-down events if there's room;
// otherwise allow events to bubble.
if (actualScrollTop < maxScroll) {
if (scrollTop < maxScroll) {
if (keyMatchers[Command.PAGE_DOWN](key)) {
scrollByWithAnimation(innerHeight);
return true;
@@ -174,21 +140,21 @@ export const Scrollable: React.FC<ScrollableProps> = ({
{ isActive: hasFocus },
);
const getScrollState = useCallback(() => {
const maxScroll = Math.max(0, size.scrollHeight - size.innerHeight);
return {
scrollTop: Math.min(getScrollTop(), maxScroll),
const getScrollState = useCallback(
() => ({
scrollTop: getScrollTop(),
scrollHeight: size.scrollHeight,
innerHeight: size.innerHeight,
};
}, [getScrollTop, size.scrollHeight, size.innerHeight]);
}),
[getScrollTop, size.scrollHeight, size.innerHeight],
);
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
const scrollableEntry = useMemo(
() => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
ref: viewportRef as React.RefObject<DOMElement>,
ref: ref as React.RefObject<DOMElement>,
getScrollState,
scrollBy: scrollByWithAnimation,
hasFocus: hasFocusCallback,
@@ -201,7 +167,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
return (
<Box
ref={viewportRefCallback}
ref={ref}
maxHeight={maxHeight}
width={width ?? maxWidth}
height={height}
@@ -217,12 +183,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
based on the children's content. It also adds a right padding to
make room for the scrollbar.
*/}
<Box
ref={contentRefCallback}
flexShrink={0}
paddingRight={1}
flexDirection="column"
>
<Box flexShrink={0} paddingRight={1} flexDirection="column">
{children}
</Box>
</Box>
@@ -479,277 +479,4 @@ describe('ScrollableList Demo Behavior', () => {
});
});
});
it('regression: remove last item and add 2 items when scrolled to bottom', async () => {
let listRef: ScrollableListRef<Item> | null = null;
let setItemsFunc: React.Dispatch<React.SetStateAction<Item[]>> | null =
null;
const TestComp = () => {
const [items, setItems] = useState<Item[]>(
Array.from({ length: 10 }, (_, i) => ({
id: String(i),
title: `Item ${i}`,
})),
);
useEffect(() => {
setItemsFunc = setItems;
}, []);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={5}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item }) => <Text>{item.title}</Text>}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
let result: ReturnType<typeof render>;
await act(async () => {
result = render(<TestComp />);
});
await result!.waitUntilReady();
// Scrolled to bottom, max scroll = 10 - 5 = 5
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(5);
});
// Remove last element and add 2 elements
await act(async () => {
setItemsFunc!((prev) => {
const next = prev.slice(0, prev.length - 1);
next.push({ id: '10', title: 'Item 10' });
next.push({ id: '11', title: 'Item 11' });
return next;
});
});
await result!.waitUntilReady();
// Auto scrolls to new bottom: max scroll = 11 - 5 = 6
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(6);
});
// Scroll up slightly
await act(async () => {
listRef?.scrollBy(-2);
});
await result!.waitUntilReady();
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(4);
});
// Scroll back to bottom
await act(async () => {
listRef?.scrollToEnd();
});
await result!.waitUntilReady();
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(6);
});
// Add two more elements
await act(async () => {
setItemsFunc!((prev) => [
...prev,
{ id: '12', title: 'Item 12' },
{ id: '13', title: 'Item 13' },
]);
});
await result!.waitUntilReady();
// Auto scrolls to bottom: max scroll = 13 - 5 = 8
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(8);
});
result!.unmount();
});
it('regression: bottom-most element changes size but list does not update', async () => {
let listRef: ScrollableListRef<Item> | null = null;
let expandLastFunc: (() => void) | null = null;
const ItemWithState = ({
item,
isLast,
}: {
item: Item;
isLast: boolean;
}) => {
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (isLast) {
expandLastFunc = () => setExpanded(true);
}
}, [isLast]);
return (
<Box flexDirection="column">
<Text>{item.title}</Text>
{expanded && <Text>Expanded content</Text>}
</Box>
);
};
const TestComp = () => {
// items array is stable
const [items] = useState(() =>
Array.from({ length: 5 }, (_, i) => ({
id: String(i),
title: `Item ${i}`,
})),
);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={4}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item, index }) => (
<ItemWithState item={item} isLast={index === 4} />
)}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
let result: ReturnType<typeof render>;
await act(async () => {
result = render(<TestComp />);
});
await result!.waitUntilReady();
// Initially, total height is 5. viewport is 4. scroll is 1.
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(1);
});
// Expand the last item locally, without re-rendering the list!
await act(async () => {
expandLastFunc!();
});
await result!.waitUntilReady();
// The total height becomes 6. It should remain scrolled to bottom, so scroll becomes 2.
// This is expected to FAIL currently because VirtualizedList won't remeasure
// unless data changes or container height changes.
await waitFor(
() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(2);
},
{ timeout: 1000 },
);
result!.unmount();
});
it('regression: prepending items does not corrupt heights (total height correct)', async () => {
let listRef: ScrollableListRef<Item> | null = null;
let setItemsFunc: React.Dispatch<React.SetStateAction<Item[]>> | null =
null;
const TestComp = () => {
// Items 1 to 5. Item 1 is very tall.
const [items, setItems] = useState<Item[]>(
Array.from({ length: 5 }, (_, i) => ({
id: String(i + 1),
title: `Item ${i + 1}`,
})),
);
useEffect(() => {
setItemsFunc = setItems;
}, []);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={10}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item }) => (
<Box height={item.id === '1' ? 10 : 2}>
<Text>{item.title}</Text>
</Box>
)}
estimatedItemHeight={() => 2}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
let result: ReturnType<typeof render>;
await act(async () => {
result = render(<TestComp />);
});
await result!.waitUntilReady();
// Scroll is at bottom.
// Heights: Item 1: 10, Item 2: 2, Item 3: 2, Item 4: 2, Item 5: 2.
// Total height = 18. Container = 10. Max scroll = 8.
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(8);
});
// Prepend an item!
await act(async () => {
setItemsFunc!((prev) => [{ id: '0', title: 'Item 0' }, ...prev]);
});
await result!.waitUntilReady();
// Now items: 0(2), 1(10), 2(2), 3(2), 4(2), 5(2).
// Total height = 20. Container = 10. Max scroll = 10.
// Auto-scrolls to bottom because it was sticking!
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(10);
});
result!.unmount();
});
});
@@ -10,7 +10,7 @@ import {
useImperativeHandle,
useCallback,
useMemo,
useLayoutEffect,
useEffect,
} from 'react';
import type React from 'react';
import {
@@ -105,7 +105,7 @@ function ScrollableList<T>(
smoothScrollState.current.active = false;
}, []);
useLayoutEffect(() => stopSmoothScroll, [stopSmoothScroll]);
useEffect(() => stopSmoothScroll, [stopSmoothScroll]);
const smoothScrollTo = useCallback(
(
@@ -120,19 +120,15 @@ function ScrollableList<T>(
innerHeight: 0,
};
const {
scrollTop: rawStartScrollTop,
scrollTop: startScrollTop,
scrollHeight,
innerHeight,
} = scrollState;
const maxScrollTop = Math.max(0, scrollHeight - innerHeight);
const startScrollTop = Math.min(rawStartScrollTop, maxScrollTop);
let effectiveTarget = targetScrollTop;
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
if (targetScrollTop === SCROLL_TO_ITEM_END) {
effectiveTarget = maxScrollTop;
}
@@ -142,11 +138,8 @@ function ScrollableList<T>(
);
if (duration === 0) {
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
if (targetScrollTop === SCROLL_TO_ITEM_END) {
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
} else {
virtualizedListRef.current?.scrollTo(Math.round(clampedTarget));
}
@@ -175,11 +168,8 @@ function ScrollableList<T>(
ease;
if (progress >= 1) {
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
if (targetScrollTop === SCROLL_TO_ITEM_END) {
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
} else {
virtualizedListRef.current?.scrollTo(Math.round(current));
}
@@ -210,13 +200,9 @@ function ScrollableList<T>(
) {
const direction = keyMatchers[Command.PAGE_UP](key) ? -1 : 1;
const scrollState = getScrollState();
const maxScroll = Math.max(
0,
scrollState.scrollHeight - scrollState.innerHeight,
);
const current = smoothScrollState.current.active
? smoothScrollState.current.to
: Math.min(scrollState.scrollTop, maxScroll);
: scrollState.scrollTop;
const innerHeight = scrollState.innerHeight;
smoothScrollTo(current + direction * innerHeight);
return true;
@@ -5,7 +5,6 @@
*/
import { render } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
import { Text, Box } from 'ink';
import {
@@ -276,11 +275,9 @@ describe('<VirtualizedList />', () => {
await waitUntilReady();
// Now Item 0 is 1px, so Items 1-9 should also be visible to fill 10px
await waitFor(() => {
expect(lastFrame()).toContain('Item 0');
expect(lastFrame()).toContain('Item 1');
expect(lastFrame()).toContain('Item 9');
});
expect(lastFrame()).toContain('Item 0');
expect(lastFrame()).toContain('Item 1');
expect(lastFrame()).toContain('Item 9');
unmount();
});
@@ -10,6 +10,7 @@ import {
useLayoutEffect,
forwardRef,
useImperativeHandle,
useEffect,
useMemo,
useCallback,
} from 'react';
@@ -18,7 +19,7 @@ import { theme } from '../../semantic-colors.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { type DOMElement, Box, ResizeObserver } from 'ink';
import { type DOMElement, measureElement, Box } from 'ink';
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
@@ -80,7 +81,7 @@ function VirtualizedList<T>(
} = props;
const { copyModeEnabled } = useUIState();
const dataRef = useRef(data);
useLayoutEffect(() => {
useEffect(() => {
dataRef.current = data;
}, [data]);
@@ -107,7 +108,6 @@ function VirtualizedList<T>(
return { index: 0, offset: 0 };
});
const [isStickingToBottom, setIsStickingToBottom] = useState(() => {
const scrollToEnd =
initialScrollIndex === SCROLL_TO_ITEM_END ||
@@ -116,75 +116,73 @@ function VirtualizedList<T>(
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
return scrollToEnd;
});
const containerRef = useRef<DOMElement | null>(null);
const containerRef = useRef<DOMElement>(null);
const [containerHeight, setContainerHeight] = useState(0);
const itemRefs = useRef<Array<DOMElement | null>>([]);
const [heights, setHeights] = useState<Record<string, number>>({});
const [heights, setHeights] = useState<number[]>([]);
const isInitialScrollSet = useRef(false);
const containerObserverRef = useRef<ResizeObserver | null>(null);
const nodeToKeyRef = useRef(new WeakMap<DOMElement, string>());
const containerRefCallback = useCallback((node: DOMElement | null) => {
containerObserverRef.current?.disconnect();
containerRef.current = node;
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setContainerHeight(Math.round(entry.contentRect.height));
}
});
observer.observe(node);
containerObserverRef.current = observer;
}
}, []);
const itemsObserver = useMemo(
() =>
new ResizeObserver((entries) => {
setHeights((prev) => {
let next: Record<string, number> | null = null;
for (const entry of entries) {
const key = nodeToKeyRef.current.get(entry.target);
if (key !== undefined) {
const height = Math.round(entry.contentRect.height);
if (prev[key] !== height) {
if (!next) {
next = { ...prev };
}
next[key] = height;
}
}
}
return next ?? prev;
});
}),
[],
);
useLayoutEffect(
() => () => {
containerObserverRef.current?.disconnect();
itemsObserver.disconnect();
},
[itemsObserver],
);
const { totalHeight, offsets } = useMemo(() => {
const offsets: number[] = [0];
let totalHeight = 0;
for (let i = 0; i < data.length; i++) {
const key = keyExtractor(data[i], i);
const height = heights[key] ?? estimatedItemHeight(i);
const height = heights[i] ?? estimatedItemHeight(i);
totalHeight += height;
offsets.push(totalHeight);
}
return { totalHeight, offsets };
}, [heights, data, estimatedItemHeight, keyExtractor]);
}, [heights, data, estimatedItemHeight]);
const scrollableContainerHeight = containerHeight;
useEffect(() => {
setHeights((prevHeights) => {
if (data.length === prevHeights.length) {
return prevHeights;
}
const newHeights = [...prevHeights];
if (data.length < prevHeights.length) {
newHeights.length = data.length;
} else {
for (let i = prevHeights.length; i < data.length; i++) {
newHeights[i] = estimatedItemHeight(i);
}
}
return newHeights;
});
}, [data, estimatedItemHeight]);
// This layout effect needs to run on every render to correctly measure the
// container and ensure we recompute the layout if it has changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
if (containerRef.current) {
const height = Math.round(measureElement(containerRef.current).height);
if (containerHeight !== height) {
setContainerHeight(height);
}
}
let newHeights: number[] | null = null;
for (let i = startIndex; i <= endIndex; i++) {
const itemRef = itemRefs.current[i];
if (itemRef) {
const height = Math.round(measureElement(itemRef).height);
if (height !== heights[i]) {
if (!newHeights) {
newHeights = [...heights];
}
newHeights[i] = height;
}
}
}
if (newHeights) {
setHeights(newHeights);
}
});
const scrollableContainerHeight = containerRef.current
? Math.round(measureElement(containerRef.current).height)
: containerHeight;
const getAnchorForScrollTop = useCallback(
(
@@ -201,36 +199,23 @@ function VirtualizedList<T>(
[],
);
const actualScrollTop = useMemo(() => {
const scrollTop = useMemo(() => {
const offset = offsets[scrollAnchor.index];
if (typeof offset !== 'number') {
return 0;
}
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
const item = data[scrollAnchor.index];
const key = item ? keyExtractor(item, scrollAnchor.index) : '';
const itemHeight = heights[key] ?? 0;
const itemHeight = heights[scrollAnchor.index] ?? 0;
return offset + itemHeight - scrollableContainerHeight;
}
return offset + scrollAnchor.offset;
}, [
scrollAnchor,
offsets,
heights,
scrollableContainerHeight,
data,
keyExtractor,
]);
const scrollTop = isStickingToBottom
? Number.MAX_SAFE_INTEGER
: actualScrollTop;
}, [scrollAnchor, offsets, heights, scrollableContainerHeight]);
const prevDataLength = useRef(data.length);
const prevTotalHeight = useRef(totalHeight);
const prevScrollTop = useRef(actualScrollTop);
const prevScrollTop = useRef(scrollTop);
const prevContainerHeight = useRef(scrollableContainerHeight);
useLayoutEffect(() => {
@@ -241,7 +226,9 @@ function VirtualizedList<T>(
prevTotalHeight.current - prevContainerHeight.current - 1;
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
// If the user was at the bottom, they are now sticking. This handles
// manually scrolling back to the bottom.
if (wasAtBottom && scrollTop >= prevScrollTop.current) {
setIsStickingToBottom(true);
}
@@ -249,6 +236,9 @@ function VirtualizedList<T>(
const containerChanged =
prevContainerHeight.current !== scrollableContainerHeight;
// We scroll to the end if:
// 1. The list grew AND we were already at the bottom (or sticking).
// 2. We are sticking to the bottom AND the container size changed.
if (
(listGrew && (isStickingToBottom || wasAtBottom)) ||
(isStickingToBottom && containerChanged)
@@ -257,28 +247,34 @@ function VirtualizedList<T>(
index: data.length > 0 ? data.length - 1 : 0,
offset: SCROLL_TO_ITEM_END,
});
// If we are scrolling to the bottom, we are by definition sticking.
if (!isStickingToBottom) {
setIsStickingToBottom(true);
}
} else if (
}
// Scenario 2: The list has changed (shrunk) in a way that our
// current scroll position or anchor is invalid. We should adjust to the bottom.
else if (
(scrollAnchor.index >= data.length ||
actualScrollTop > totalHeight - scrollableContainerHeight) &&
scrollTop > totalHeight - scrollableContainerHeight) &&
data.length > 0
) {
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
} else if (data.length === 0) {
// List is now empty, reset scroll to top.
setScrollAnchor({ index: 0, offset: 0 });
}
// Update refs for the next render cycle.
prevDataLength.current = data.length;
prevTotalHeight.current = totalHeight;
prevScrollTop.current = actualScrollTop;
prevScrollTop.current = scrollTop;
prevContainerHeight.current = scrollableContainerHeight;
}, [
data.length,
totalHeight,
actualScrollTop,
scrollTop,
scrollableContainerHeight,
scrollAnchor.index,
getAnchorForScrollTop,
@@ -338,10 +334,10 @@ function VirtualizedList<T>(
const startIndex = Math.max(
0,
findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1,
findLastIndex(offsets, (offset) => offset <= scrollTop) - 1,
);
const endIndexOffset = offsets.findIndex(
(offset) => offset > actualScrollTop + scrollableContainerHeight,
(offset) => offset > scrollTop + scrollableContainerHeight,
);
const endIndex =
endIndexOffset === -1
@@ -352,32 +348,6 @@ function VirtualizedList<T>(
const bottomSpacerHeight =
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
// Maintain a stable set of observed nodes using useLayoutEffect
const observedNodes = useRef<Set<DOMElement>>(new Set());
useLayoutEffect(() => {
const currentNodes = new Set<DOMElement>();
for (let i = startIndex; i <= endIndex; i++) {
const node = itemRefs.current[i];
const item = data[i];
if (node && item) {
currentNodes.add(node);
const key = keyExtractor(item, i);
// Always update the key mapping because React can reuse nodes at different indices/keys
nodeToKeyRef.current.set(node, key);
if (!observedNodes.current.has(node)) {
itemsObserver.observe(node);
}
}
}
for (const node of observedNodes.current) {
if (!currentNodes.has(node)) {
itemsObserver.unobserve(node);
nodeToKeyRef.current.delete(node);
}
}
observedNodes.current = currentNodes;
});
const renderedItems = [];
for (let i = startIndex; i <= endIndex; i++) {
const item = data[i];
@@ -386,8 +356,6 @@ function VirtualizedList<T>(
<Box
key={keyExtractor(item, i)}
width="100%"
flexDirection="column"
flexShrink={0}
ref={(el) => {
itemRefs.current[i] = el;
}}
@@ -408,39 +376,27 @@ function VirtualizedList<T>(
setIsStickingToBottom(false);
}
const currentScrollTop = getScrollTop();
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
const actualCurrent = Math.min(currentScrollTop, maxScroll);
let newScrollTop = Math.max(0, actualCurrent + delta);
if (newScrollTop >= maxScroll) {
setIsStickingToBottom(true);
newScrollTop = Number.MAX_SAFE_INTEGER;
}
setPendingScrollTop(newScrollTop);
setScrollAnchor(
getAnchorForScrollTop(Math.min(newScrollTop, maxScroll), offsets),
const newScrollTop = Math.max(
0,
Math.min(
totalHeight - scrollableContainerHeight,
currentScrollTop + delta,
),
);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
},
scrollTo: (offset: number) => {
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) {
setIsStickingToBottom(true);
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
if (data.length > 0) {
setScrollAnchor({
index: data.length - 1,
offset: SCROLL_TO_ITEM_END,
});
}
} else {
setIsStickingToBottom(false);
const newScrollTop = Math.max(0, offset);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
}
setIsStickingToBottom(false);
const newScrollTop = Math.max(
0,
Math.min(totalHeight - scrollableContainerHeight, offset),
);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
},
scrollToEnd: () => {
setIsStickingToBottom(true);
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
if (data.length > 0) {
setScrollAnchor({
index: data.length - 1,
@@ -460,14 +416,10 @@ function VirtualizedList<T>(
setIsStickingToBottom(false);
const offset = offsets[index];
if (offset !== undefined) {
const maxScroll = Math.max(
0,
totalHeight - scrollableContainerHeight,
);
const newScrollTop = Math.max(
0,
Math.min(
maxScroll,
totalHeight - scrollableContainerHeight,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
@@ -489,14 +441,10 @@ function VirtualizedList<T>(
if (index !== -1) {
const offset = offsets[index];
if (offset !== undefined) {
const maxScroll = Math.max(
0,
totalHeight - scrollableContainerHeight,
);
const newScrollTop = Math.max(
0,
Math.min(
maxScroll,
totalHeight - scrollableContainerHeight,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
@@ -506,14 +454,11 @@ function VirtualizedList<T>(
}
},
getScrollIndex: () => scrollAnchor.index,
getScrollState: () => {
const maxScroll = Math.max(0, totalHeight - containerHeight);
return {
scrollTop: Math.min(getScrollTop(), maxScroll),
scrollHeight: totalHeight,
innerHeight: containerHeight,
};
},
getScrollState: () => ({
scrollTop: getScrollTop(),
scrollHeight: totalHeight,
innerHeight: containerHeight,
}),
}),
[
offsets,
@@ -530,7 +475,7 @@ function VirtualizedList<T>(
return (
<Box
ref={containerRefCallback}
ref={containerRef}
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
overflowX="hidden"
scrollTop={copyModeEnabled ? 0 : scrollTop}
@@ -544,7 +489,7 @@ function VirtualizedList<T>(
flexShrink={0}
width="100%"
flexDirection="column"
marginTop={copyModeEnabled ? -actualScrollTop : 0}
marginTop={copyModeEnabled ? -scrollTop : 0}
>
<Box height={topSpacerHeight} flexShrink={0} />
{renderedItems}
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<MaxSizedBox /> > accounts for additionalHiddenLinesCount 1`] = `
"... first 7 lines hidden (Ctrl+O to show) ...
"... first 7 lines hidden ...
Line 3
"
`;
@@ -16,12 +16,12 @@ Line 6
Line 7
Line 8
Line 9
... last 21 lines hidden (Ctrl+O to show) ...
... last 21 lines hidden ...
"
`;
exports[`<MaxSizedBox /> > clips a long single text child from the top 1`] = `
"... first 21 lines hidden (Ctrl+O to show) ...
"... first 21 lines hidden ...
Line 22
Line 23
Line 24
@@ -39,7 +39,7 @@ exports[`<MaxSizedBox /> > does not leak content after hidden indicator with bot
- Step 1: Do something important
- Step 2: Do something important
... last 18 lines hidden (Ctrl+O to show) ...
... last 18 lines hidden ...
"
`;
@@ -58,12 +58,12 @@ Line 3 direct child
exports[`<MaxSizedBox /> > hides lines at the end when content exceeds maxHeight and overflowDirection is bottom 1`] = `
"Line 1
... last 2 lines hidden (Ctrl+O to show) ...
... last 2 lines hidden ...
"
`;
exports[`<MaxSizedBox /> > hides lines when content exceeds maxHeight 1`] = `
"... first 2 lines hidden (Ctrl+O to show) ...
"... first 2 lines hidden ...
Line 3
"
`;
@@ -74,13 +74,13 @@ exports[`<MaxSizedBox /> > renders children without truncation when they fit 1`]
`;
exports[`<MaxSizedBox /> > shows plural "lines" when more than one line is hidden 1`] = `
"... first 2 lines hidden (Ctrl+O to show) ...
"... first 2 lines hidden ...
Line 3
"
`;
exports[`<MaxSizedBox /> > shows singular "line" when exactly one line is hidden 1`] = `
"... first 1 line hidden (Ctrl+O to show) ...
"... first 1 line hidden ...
Line 1
"
`;
@@ -1,80 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import {
useAlternateBuffer,
isAlternateBufferEnabled,
} from './useAlternateBuffer.js';
import type { Config } from '@google/gemini-cli-core';
vi.mock('../contexts/ConfigContext.js', () => ({
useConfig: vi.fn(),
}));
const mockUseConfig = vi.mocked(
await import('../contexts/ConfigContext.js').then((m) => m.useConfig),
);
describe('useAlternateBuffer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return false when config.getUseAlternateBuffer returns false', () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = renderHook(() => useAlternateBuffer());
expect(result.current).toBe(false);
});
it('should return true when config.getUseAlternateBuffer returns true', () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = renderHook(() => useAlternateBuffer());
expect(result.current).toBe(true);
});
it('should return the immutable config value, not react to settings changes', () => {
const mockConfig = {
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>;
mockUseConfig.mockReturnValue(mockConfig);
const { result, rerender } = renderHook(() => useAlternateBuffer());
// Value should remain true even after rerender
expect(result.current).toBe(true);
rerender();
expect(result.current).toBe(true);
});
});
describe('isAlternateBufferEnabled', () => {
it('should return true when config.getUseAlternateBuffer returns true', () => {
const config = {
getUseAlternateBuffer: () => true,
} as unknown as Config;
expect(isAlternateBufferEnabled(config)).toBe(true);
});
it('should return false when config.getUseAlternateBuffer returns false', () => {
const config = {
getUseAlternateBuffer: () => false,
} as unknown as Config;
expect(isAlternateBufferEnabled(config)).toBe(false);
});
});
@@ -4,14 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useConfig } from '../contexts/ConfigContext.js';
import type { Config } from '@google/gemini-cli-core';
import { useSettings } from '../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../config/settings.js';
export const isAlternateBufferEnabled = (config: Config): boolean =>
config.getUseAlternateBuffer();
export const isAlternateBufferEnabled = (settings: LoadedSettings): boolean =>
settings.merged.ui.useAlternateBuffer === true;
// This is read from Config so that the UI reads the same value per application session
export const useAlternateBuffer = (): boolean => {
const config = useConfig();
return isAlternateBufferEnabled(config);
const settings = useSettings();
return isAlternateBufferEnabled(settings);
};
@@ -10,7 +10,6 @@ import {
type CodeAssistServer,
UserTierId,
getCodeAssistServer,
debugLogger,
} from '@google/gemini-cli-core';
export interface PrivacyState {
@@ -104,12 +103,7 @@ async function getRemoteDataCollectionOptIn(
): Promise<boolean> {
try {
const resp = await server.getCodeAssistGlobalUserSetting();
if (resp.freeTierDataCollectionOptin === undefined) {
debugLogger.warn(
'Warning: Code Assist API did not return freeTierDataCollectionOptin. Defaulting to true.',
);
}
return resp.freeTierDataCollectionOptin ?? true;
return resp.freeTierDataCollectionOptin;
} catch (error: unknown) {
if (error && typeof error === 'object' && 'response' in error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -134,10 +128,5 @@ async function setRemoteDataCollectionOptIn(
cloudaicompanionProject: server.projectId,
freeTierDataCollectionOptin: optIn,
});
if (resp.freeTierDataCollectionOptin === undefined) {
debugLogger.warn(
`Warning: Code Assist API did not return freeTierDataCollectionOptin. Defaulting to ${optIn}.`,
);
}
return resp.freeTierDataCollectionOptin ?? optIn;
return resp.freeTierDataCollectionOptin;
}
+4 -6
View File
@@ -15,16 +15,14 @@ export function useTimedMessage<T>(durationMs: number) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const showMessage = useCallback(
(msg: T | null) => {
(msg: T) => {
setMessage(msg);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (msg !== null) {
timeoutRef.current = setTimeout(() => {
setMessage(null);
}, durationMs);
}
timeoutRef.current = setTimeout(() => {
setMessage(null);
}, durationMs);
},
[durationMs],
);
@@ -6,14 +6,223 @@
import React from 'react';
import { Text } from 'ink';
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
import chalk from 'chalk';
import {
resolveColor,
INK_SUPPORTED_NAMES,
INK_NAME_TO_HEX_MAP,
} from '../themes/color-utils.js';
import { theme } from '../semantic-colors.js';
import { debugLogger } from '@google/gemini-cli-core';
import { stripUnsafeCharacters } from './textUtils.js';
// Constants for Markdown parsing
const BOLD_MARKER_LENGTH = 2; // For "**"
const ITALIC_MARKER_LENGTH = 1; // For "*" or "_"
const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~")
const INLINE_CODE_MARKER_LENGTH = 1; // For "`"
const UNDERLINE_TAG_START_LENGTH = 3; // For "<u>"
const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
interface RenderInlineProps {
text: string;
defaultColor?: string;
}
/**
* Helper to apply color to a string using ANSI escape codes,
* consistent with how Ink's colorize works.
*/
const ansiColorize = (str: string, color: string | undefined): string => {
if (!color) return str;
const resolved = resolveColor(color);
if (!resolved) return str;
if (resolved.startsWith('#')) {
return chalk.hex(resolved)(str);
}
const mappedHex = INK_NAME_TO_HEX_MAP[resolved];
if (mappedHex) {
return chalk.hex(mappedHex)(str);
}
if (INK_SUPPORTED_NAMES.has(resolved)) {
switch (resolved) {
case 'black':
return chalk.black(str);
case 'red':
return chalk.red(str);
case 'green':
return chalk.green(str);
case 'yellow':
return chalk.yellow(str);
case 'blue':
return chalk.blue(str);
case 'magenta':
return chalk.magenta(str);
case 'cyan':
return chalk.cyan(str);
case 'white':
return chalk.white(str);
case 'gray':
case 'grey':
return chalk.gray(str);
default:
return str;
}
}
return str;
};
/**
* Converts markdown text into a string with ANSI escape codes.
* This mirrors the parsing logic in InlineMarkdownRenderer.tsx
*/
export const parseMarkdownToANSI = (
text: string,
defaultColor?: string,
): string => {
const baseColor = defaultColor ?? theme.text.primary;
// Early return for plain text without markdown or URLs
if (!/[*_~`<[https?:]/.test(text)) {
return ansiColorize(text, baseColor);
}
let result = '';
const inlineRegex =
/(\*\*\*.*?\*\*\*|\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>|https?:\/\/\S+)/g;
let lastIndex = 0;
let match;
while ((match = inlineRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
result += ansiColorize(text.slice(lastIndex, match.index), baseColor);
}
const fullMatch = match[0];
let styledPart = '';
try {
if (
fullMatch.endsWith('***') &&
fullMatch.startsWith('***') &&
fullMatch.length > (BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH) * 2
) {
styledPart = chalk.bold(
chalk.italic(
parseMarkdownToANSI(
fullMatch.slice(
BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH,
-BOLD_MARKER_LENGTH - ITALIC_MARKER_LENGTH,
),
baseColor,
),
),
);
} else if (
fullMatch.endsWith('**') &&
fullMatch.startsWith('**') &&
fullMatch.length > BOLD_MARKER_LENGTH * 2
) {
styledPart = chalk.bold(
parseMarkdownToANSI(
fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH),
baseColor,
),
);
} else if (
fullMatch.length > ITALIC_MARKER_LENGTH * 2 &&
((fullMatch.startsWith('*') && fullMatch.endsWith('*')) ||
(fullMatch.startsWith('_') && fullMatch.endsWith('_'))) &&
!/\w/.test(text.substring(match.index - 1, match.index)) &&
!/\w/.test(
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 1),
) &&
!/\S[./\\]/.test(text.substring(match.index - 2, match.index)) &&
!/[./\\]\S/.test(
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 2),
)
) {
styledPart = chalk.italic(
parseMarkdownToANSI(
fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH),
baseColor,
),
);
} else if (
fullMatch.startsWith('~~') &&
fullMatch.endsWith('~~') &&
fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2
) {
styledPart = chalk.strikethrough(
parseMarkdownToANSI(
fullMatch.slice(
STRIKETHROUGH_MARKER_LENGTH,
-STRIKETHROUGH_MARKER_LENGTH,
),
baseColor,
),
);
} else if (
fullMatch.startsWith('`') &&
fullMatch.endsWith('`') &&
fullMatch.length > INLINE_CODE_MARKER_LENGTH
) {
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
if (codeMatch && codeMatch[2]) {
styledPart = ansiColorize(codeMatch[2], theme.text.accent);
}
} else if (
fullMatch.startsWith('[') &&
fullMatch.includes('](') &&
fullMatch.endsWith(')')
) {
const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/);
if (linkMatch) {
const linkText = linkMatch[1];
const url = linkMatch[2];
styledPart =
parseMarkdownToANSI(linkText, baseColor) +
ansiColorize(' (', baseColor) +
ansiColorize(url, theme.text.link) +
ansiColorize(')', baseColor);
}
} else if (
fullMatch.startsWith('<u>') &&
fullMatch.endsWith('</u>') &&
fullMatch.length >
UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1
) {
styledPart = chalk.underline(
parseMarkdownToANSI(
fullMatch.slice(
UNDERLINE_TAG_START_LENGTH,
-UNDERLINE_TAG_END_LENGTH,
),
baseColor,
),
);
} else if (fullMatch.match(/^https?:\/\//)) {
styledPart = ansiColorize(fullMatch, theme.text.link);
}
} catch (e) {
debugLogger.warn('Error parsing inline markdown part:', fullMatch, e);
styledPart = '';
}
result += styledPart || ansiColorize(fullMatch, baseColor);
lastIndex = inlineRegex.lastIndex;
}
if (lastIndex < text.length) {
result += ansiColorize(text.slice(lastIndex), baseColor);
}
return result;
};
const RenderInlineInternal: React.FC<RenderInlineProps> = ({
text: rawText,
defaultColor,
+1 -1
View File
@@ -17,7 +17,7 @@ import {
widestLineFromStyledChars,
} from 'ink';
import { theme } from '../semantic-colors.js';
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
import { parseMarkdownToANSI } from './InlineMarkdownRenderer.js';
import { stripUnsafeCharacters } from './textUtils.js';
interface TableRendererProps {
@@ -6,7 +6,7 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import chalk from 'chalk';
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
import { parseMarkdownToANSI } from './InlineMarkdownRenderer.js';
// Mock the theme to use explicit colors instead of empty strings from the default theme.
// This ensures that ansiColorize actually applies ANSI codes that we can verify.
@@ -1,216 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import chalk from 'chalk';
import {
resolveColor,
INK_SUPPORTED_NAMES,
INK_NAME_TO_HEX_MAP,
} from '../themes/color-utils.js';
import { theme } from '../semantic-colors.js';
import { debugLogger } from '@google/gemini-cli-core';
// Constants for Markdown parsing
const BOLD_MARKER_LENGTH = 2; // For "**"
const ITALIC_MARKER_LENGTH = 1; // For "*" or "_"
const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~")
const INLINE_CODE_MARKER_LENGTH = 1; // For "`"
const UNDERLINE_TAG_START_LENGTH = 3; // For "<u>"
const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
/**
* Helper to apply color to a string using ANSI escape codes,
* consistent with how Ink's colorize works.
*/
const ansiColorize = (str: string, color: string | undefined): string => {
if (!color) return str;
const resolved = resolveColor(color);
if (!resolved) return str;
if (resolved.startsWith('#')) {
return chalk.hex(resolved)(str);
}
const mappedHex = INK_NAME_TO_HEX_MAP[resolved];
if (mappedHex) {
return chalk.hex(mappedHex)(str);
}
if (INK_SUPPORTED_NAMES.has(resolved)) {
switch (resolved) {
case 'black':
return chalk.black(str);
case 'red':
return chalk.red(str);
case 'green':
return chalk.green(str);
case 'yellow':
return chalk.yellow(str);
case 'blue':
return chalk.blue(str);
case 'magenta':
return chalk.magenta(str);
case 'cyan':
return chalk.cyan(str);
case 'white':
return chalk.white(str);
case 'gray':
case 'grey':
return chalk.gray(str);
default:
return str;
}
}
return str;
};
/**
* Converts markdown text into a string with ANSI escape codes.
* This mirrors the parsing logic in InlineMarkdownRenderer.tsx
*/
export const parseMarkdownToANSI = (
text: string,
defaultColor?: string,
): string => {
const baseColor = defaultColor ?? theme.text.primary;
// Early return for plain text without markdown or URLs
if (!/[*_~`<[https?:]/.test(text)) {
return ansiColorize(text, baseColor);
}
let result = '';
const inlineRegex =
/(\*\*\*.*?\*\*\*|\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>|https?:\/\/\S+)/g;
let lastIndex = 0;
let match;
while ((match = inlineRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
result += ansiColorize(text.slice(lastIndex, match.index), baseColor);
}
const fullMatch = match[0];
let styledPart = '';
try {
if (
fullMatch.endsWith('***') &&
fullMatch.startsWith('***') &&
fullMatch.length > (BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH) * 2
) {
styledPart = chalk.bold(
chalk.italic(
parseMarkdownToANSI(
fullMatch.slice(
BOLD_MARKER_LENGTH + ITALIC_MARKER_LENGTH,
-BOLD_MARKER_LENGTH - ITALIC_MARKER_LENGTH,
),
baseColor,
),
),
);
} else if (
fullMatch.endsWith('**') &&
fullMatch.startsWith('**') &&
fullMatch.length > BOLD_MARKER_LENGTH * 2
) {
styledPart = chalk.bold(
parseMarkdownToANSI(
fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH),
baseColor,
),
);
} else if (
fullMatch.length > ITALIC_MARKER_LENGTH * 2 &&
((fullMatch.startsWith('*') && fullMatch.endsWith('*')) ||
(fullMatch.startsWith('_') && fullMatch.endsWith('_'))) &&
!/\w/.test(text.substring(match.index - 1, match.index)) &&
!/\w/.test(
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 1),
) &&
!/\S[./\\]/.test(text.substring(match.index - 2, match.index)) &&
!/[./\\]\S/.test(
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 2),
)
) {
styledPart = chalk.italic(
parseMarkdownToANSI(
fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH),
baseColor,
),
);
} else if (
fullMatch.startsWith('~~') &&
fullMatch.endsWith('~~') &&
fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2
) {
styledPart = chalk.strikethrough(
parseMarkdownToANSI(
fullMatch.slice(
STRIKETHROUGH_MARKER_LENGTH,
-STRIKETHROUGH_MARKER_LENGTH,
),
baseColor,
),
);
} else if (
fullMatch.startsWith('`') &&
fullMatch.endsWith('`') &&
fullMatch.length > INLINE_CODE_MARKER_LENGTH
) {
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
if (codeMatch && codeMatch[2]) {
styledPart = ansiColorize(codeMatch[2], theme.text.accent);
}
} else if (
fullMatch.startsWith('[') &&
fullMatch.includes('](') &&
fullMatch.endsWith(')')
) {
const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/);
if (linkMatch) {
const linkText = linkMatch[1];
const url = linkMatch[2];
styledPart =
parseMarkdownToANSI(linkText, baseColor) +
ansiColorize(' (', baseColor) +
ansiColorize(url, theme.text.link) +
ansiColorize(')', baseColor);
}
} else if (
fullMatch.startsWith('<u>') &&
fullMatch.endsWith('</u>') &&
fullMatch.length >
UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1
) {
styledPart = chalk.underline(
parseMarkdownToANSI(
fullMatch.slice(
UNDERLINE_TAG_START_LENGTH,
-UNDERLINE_TAG_END_LENGTH,
),
baseColor,
),
);
} else if (fullMatch.match(/^https?:\/\//)) {
styledPart = ansiColorize(fullMatch, theme.text.link);
}
} catch (e) {
debugLogger.warn('Error parsing inline markdown part:', fullMatch, e);
styledPart = '';
}
result += styledPart || ansiColorize(fullMatch, baseColor);
lastIndex = inlineRegex.lastIndex;
}
if (lastIndex < text.length) {
result += ansiColorize(text.slice(lastIndex), baseColor);
}
return result;
};
+24 -6
View File
@@ -4,11 +4,29 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { calculateMainAreaWidth } from './ui-sizing.js';
import type { Config } from '@google/gemini-cli-core';
import { type LoadedSettings } from '../../config/settings.js';
// Mock dependencies
const mocks = vi.hoisted(() => ({
isAlternateBufferEnabled: vi.fn(),
}));
vi.mock('../hooks/useAlternateBuffer.js', () => ({
isAlternateBufferEnabled: mocks.isAlternateBufferEnabled,
}));
describe('ui-sizing', () => {
const createSettings = (useFullWidth?: boolean): LoadedSettings =>
({
merged: {
ui: {
useFullWidth,
},
},
}) as unknown as LoadedSettings;
describe('calculateMainAreaWidth', () => {
it.each([
// expected, width, altBuffer
@@ -19,10 +37,10 @@ describe('ui-sizing', () => {
])(
'should return %i when width=%i and altBuffer=%s',
(expected, width, altBuffer) => {
const mockConfig = {
getUseAlternateBuffer: () => altBuffer,
} as unknown as Config;
expect(calculateMainAreaWidth(width, mockConfig)).toBe(expected);
mocks.isAlternateBufferEnabled.mockReturnValue(altBuffer);
const settings = createSettings();
expect(calculateMainAreaWidth(width, settings)).toBe(expected);
},
);
});
+3 -3
View File
@@ -4,14 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@google/gemini-cli-core';
import { type LoadedSettings } from '../../config/settings.js';
import { isAlternateBufferEnabled } from '../hooks/useAlternateBuffer.js';
export const calculateMainAreaWidth = (
terminalWidth: number,
config: Config,
settings: LoadedSettings,
): number => {
if (isAlternateBufferEnabled(config)) {
if (isAlternateBufferEnabled(settings)) {
return terminalWidth - 1;
}
return terminalWidth;
@@ -246,7 +246,7 @@ describe('converter', () => {
};
const genaiRes = fromGenerateContentResponse(codeAssistRes);
expect(genaiRes).toBeInstanceOf(GenerateContentResponse);
expect(genaiRes.candidates).toEqual(codeAssistRes.response!.candidates);
expect(genaiRes.candidates).toEqual(codeAssistRes.response.candidates);
});
it('should handle prompt feedback and usage metadata', () => {
@@ -266,10 +266,10 @@ describe('converter', () => {
};
const genaiRes = fromGenerateContentResponse(codeAssistRes);
expect(genaiRes.promptFeedback).toEqual(
codeAssistRes.response!.promptFeedback,
codeAssistRes.response.promptFeedback,
);
expect(genaiRes.usageMetadata).toEqual(
codeAssistRes.response!.usageMetadata,
codeAssistRes.response.usageMetadata,
);
});
@@ -296,7 +296,7 @@ describe('converter', () => {
};
const genaiRes = fromGenerateContentResponse(codeAssistRes);
expect(genaiRes.automaticFunctionCallingHistory).toEqual(
codeAssistRes.response!.automaticFunctionCallingHistory,
codeAssistRes.response.automaticFunctionCallingHistory,
);
});
+4 -10
View File
@@ -27,7 +27,6 @@ import type {
ToolConfig,
} from '@google/genai';
import { GenerateContentResponse } from '@google/genai';
import { debugLogger } from '../utils/debugLogger.js';
export interface CAGenerateContentRequest {
model: string;
@@ -73,12 +72,12 @@ interface VertexGenerationConfig {
}
export interface CaGenerateContentResponse {
response?: VertexGenerateContentResponse;
response: VertexGenerateContentResponse;
traceId?: string;
}
interface VertexGenerateContentResponse {
candidates?: Candidate[];
candidates: Candidate[];
automaticFunctionCallingHistory?: Content[];
promptFeedback?: GenerateContentResponsePromptFeedback;
usageMetadata?: GenerateContentResponseUsageMetadata;
@@ -95,7 +94,7 @@ interface VertexCountTokenRequest {
}
export interface CaCountTokenResponse {
totalTokens?: number;
totalTokens: number;
}
export function toCountTokenRequest(
@@ -112,13 +111,8 @@ export function toCountTokenRequest(
export function fromCountTokenResponse(
res: CaCountTokenResponse,
): CountTokensResponse {
if (res.totalTokens === undefined) {
debugLogger.warn(
'Warning: Code Assist API did not return totalTokens. Defaulting to 0.',
);
}
return {
totalTokens: res.totalTokens ?? 0,
totalTokens: res.totalTokens,
};
}
+1
View File
@@ -138,6 +138,7 @@ async function initOauthClient(
clientSecret: OAUTH_CLIENT_SECRET,
transporterOptions: {
proxy: config.getProxy(),
retry: false,
},
});
const useEncryptedStorage = getUseEncryptedStorageFlag();
+26 -40
View File
@@ -73,26 +73,19 @@ describe('CodeAssistServer', () => {
LlmRole.MAIN,
);
expect(mockRequest).toHaveBeenCalledWith({
url: expect.stringContaining(':generateContent'),
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-custom-header': 'test-value',
},
responseType: 'json',
body: expect.any(String),
signal: undefined,
retryConfig: {
retry: 3,
noResponseRetries: 3,
statusCodesToRetry: [
[429, 429],
[499, 499],
[500, 599],
],
},
});
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining(':generateContent'),
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-custom-header': 'test-value',
},
responseType: 'json',
body: expect.any(String),
signal: undefined,
}),
);
const requestBody = JSON.parse(mockRequest.mock.calls[0][0].body);
expect(requestBody.user_prompt_id).toBe('user-prompt-id');
@@ -400,26 +393,19 @@ describe('CodeAssistServer', () => {
results.push(res);
}
expect(mockRequest).toHaveBeenCalledWith({
url: expect.stringContaining(':streamGenerateContent'),
method: 'POST',
params: { alt: 'sse' },
responseType: 'stream',
body: expect.any(String),
headers: {
'Content-Type': 'application/json',
},
signal: undefined,
retryConfig: {
retry: 3,
noResponseRetries: 3,
statusCodesToRetry: [
[429, 429],
[499, 499],
[500, 599],
],
},
});
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining(':streamGenerateContent'),
method: 'POST',
params: { alt: 'sse' },
responseType: 'stream',
body: expect.any(String),
headers: {
'Content-Type': 'application/json',
},
signal: undefined,
}),
);
expect(results).toHaveLength(2);
expect(results[0].candidates?.[0].content?.parts?.[0].text).toBe('Hello');
+1 -18
View File
@@ -305,15 +305,7 @@ export class CodeAssistServer implements ContentGenerator {
responseType: 'json',
body: JSON.stringify(req),
signal,
retryConfig: {
retry: 3,
noResponseRetries: 3,
statusCodesToRetry: [
[429, 429],
[499, 499],
[500, 599],
],
},
retry: false,
});
return res.data;
}
@@ -361,15 +353,6 @@ export class CodeAssistServer implements ContentGenerator {
responseType: 'stream',
body: JSON.stringify(req),
signal,
retryConfig: {
retry: 3,
noResponseRetries: 3,
statusCodesToRetry: [
[429, 429],
[499, 499],
[500, 599],
],
},
});
return (async function* (): AsyncGenerator<T> {
+4 -21
View File
@@ -18,7 +18,6 @@ import type { AuthClient } from 'google-auth-library';
import type { ValidationHandler } from '../fallback/types.js';
import { ChangeAuthRequestedError } from '../utils/errors.js';
import { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import { debugLogger } from '../utils/debugLogger.js';
export class ProjectIdRequiredError extends Error {
constructor() {
@@ -131,20 +130,11 @@ export async function setupUser(
}
if (loadRes.currentTier) {
if (!loadRes.paidTier?.id && !loadRes.currentTier.id) {
debugLogger.warn(
'Warning: Code Assist API did not return a user tier ID. Defaulting to STANDARD tier.',
);
}
if (!loadRes.cloudaicompanionProject) {
if (projectId) {
return {
projectId,
userTier:
loadRes.paidTier?.id ??
loadRes.currentTier.id ??
UserTierId.STANDARD,
userTier: loadRes.paidTier?.id ?? loadRes.currentTier.id,
userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name,
};
}
@@ -154,20 +144,13 @@ export async function setupUser(
}
return {
projectId: loadRes.cloudaicompanionProject,
userTier:
loadRes.paidTier?.id ?? loadRes.currentTier.id ?? UserTierId.STANDARD,
userTier: loadRes.paidTier?.id ?? loadRes.currentTier.id,
userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name,
};
}
const tier = getOnboardTier(loadRes);
if (!tier.id) {
debugLogger.warn(
'Warning: Code Assist API did not return an onboarding tier ID. Defaulting to STANDARD tier.',
);
}
let onboardReq: OnboardUserRequest;
if (tier.id === UserTierId.FREE) {
// The free tier uses a managed google cloud project. Setting a project in the `onboardUser` request causes a `Precondition Failed` error.
@@ -200,7 +183,7 @@ export async function setupUser(
if (projectId) {
return {
projectId,
userTier: tier.id ?? UserTierId.STANDARD,
userTier: tier.id,
userTierName: tier.name,
};
}
@@ -210,7 +193,7 @@ export async function setupUser(
return {
projectId: lroRes.response.cloudaicompanionProject.id,
userTier: tier.id ?? UserTierId.STANDARD,
userTier: tier.id,
userTierName: tier.name,
};
}
+10 -10
View File
@@ -60,7 +60,7 @@ export interface LoadCodeAssistResponse {
* GeminiUserTier reflects the structure received from the CodeAssist when calling LoadCodeAssist.
*/
export interface GeminiUserTier {
id?: UserTierId;
id: UserTierId;
name?: string;
description?: string;
// This value is used to declare whether a given tier requires the user to configure the project setting on the IDE settings or not.
@@ -79,10 +79,10 @@ export interface GeminiUserTier {
* @param tierName name of the tier.
*/
export interface IneligibleTier {
reasonCode?: IneligibleTierReasonCode;
reasonMessage?: string;
tierId?: UserTierId;
tierName?: string;
reasonCode: IneligibleTierReasonCode;
reasonMessage: string;
tierId: UserTierId;
tierName: string;
validationErrorMessage?: string;
validationUrl?: string;
validationUrlLinkText?: string;
@@ -127,7 +127,7 @@ export type UserTierId = (typeof UserTierId)[keyof typeof UserTierId] | string;
* privacy notice.
*/
export interface PrivacyNotice {
showNotice?: boolean;
showNotice: boolean;
noticeText?: string;
}
@@ -145,7 +145,7 @@ export interface OnboardUserRequest {
* http://google3/google/longrunning/operations.proto;rcl=698857719;l=107
*/
export interface LongRunningOperationResponse {
name?: string;
name: string;
done?: boolean;
response?: OnboardUserResponse;
}
@@ -157,8 +157,8 @@ export interface LongRunningOperationResponse {
export interface OnboardUserResponse {
// tslint:disable-next-line:enforce-name-casing This is the name of the field in the proto.
cloudaicompanionProject?: {
id?: string;
name?: string;
id: string;
name: string;
};
}
@@ -195,7 +195,7 @@ export interface SetCodeAssistGlobalUserSettingRequest {
export interface CodeAssistGlobalUserSettingResponse {
cloudaicompanionProject?: string;
freeTierDataCollectionOptin?: boolean;
freeTierDataCollectionOptin: boolean;
}
/**
-25
View File
@@ -941,31 +941,6 @@ describe('Server Config (config.ts)', () => {
});
});
describe('UseAlternateBuffer Configuration', () => {
it('should default useAlternateBuffer to false when not provided', () => {
const config = new Config(baseParams);
expect(config.getUseAlternateBuffer()).toBe(false);
});
it('should set useAlternateBuffer to true when provided as true', () => {
const paramsWithAlternateBuffer: ConfigParameters = {
...baseParams,
useAlternateBuffer: true,
};
const config = new Config(paramsWithAlternateBuffer);
expect(config.getUseAlternateBuffer()).toBe(true);
});
it('should set useAlternateBuffer to false when explicitly provided as false', () => {
const paramsWithAlternateBuffer: ConfigParameters = {
...baseParams,
useAlternateBuffer: false,
};
const config = new Config(paramsWithAlternateBuffer);
expect(config.getUseAlternateBuffer()).toBe(false);
});
});
describe('UseWriteTodos Configuration', () => {
it('should default useWriteTodos to true when not provided', () => {
const config = new Config(baseParams);
-7
View File
@@ -519,7 +519,6 @@ export interface ConfigParameters {
interactive?: boolean;
trustedFolder?: boolean;
useBackgroundColor?: boolean;
useAlternateBuffer?: boolean;
useRipgrep?: boolean;
enableInteractiveShell?: boolean;
skipNextSpeakerCheck?: boolean;
@@ -703,7 +702,6 @@ export class Config {
private readonly enableInteractiveShell: boolean;
private readonly skipNextSpeakerCheck: boolean;
private readonly useBackgroundColor: boolean;
private readonly useAlternateBuffer: boolean;
private shellExecutionConfig: ShellExecutionConfig;
private readonly extensionManagement: boolean = true;
private readonly truncateToolOutputThreshold: number;
@@ -902,7 +900,6 @@ export class Config {
this.directWebFetch = params.directWebFetch ?? false;
this.useRipgrep = params.useRipgrep ?? true;
this.useBackgroundColor = params.useBackgroundColor ?? true;
this.useAlternateBuffer = params.useAlternateBuffer ?? false;
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
this.shellExecutionConfig = {
@@ -2524,10 +2521,6 @@ export class Config {
return this.useBackgroundColor;
}
getUseAlternateBuffer(): boolean {
return this.useAlternateBuffer;
}
getEnableInteractiveShell(): boolean {
return this.enableInteractiveShell;
}
+4 -1
View File
@@ -169,7 +169,10 @@ export class Storage {
}
getAutoSavedPolicyPath(): string {
return path.join(Storage.getUserPoliciesDir(), AUTO_SAVED_POLICY_FILENAME);
return path.join(
this.getWorkspacePoliciesDir(),
AUTO_SAVED_POLICY_FILENAME,
);
}
ensureProjectTempDirExists(): void {
@@ -109,7 +109,7 @@ The following tools are available in Plan Mode:
## Rules
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/plans/\`. They cannot modify source code.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify.
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
@@ -277,7 +277,7 @@ The following tools are available in Plan Mode:
## Rules
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/plans/\`. They cannot modify source code.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify.
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
@@ -564,7 +564,7 @@ The following tools are available in Plan Mode:
## Rules
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/project-temp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/project-temp/plans/\`. They cannot modify source code.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify.
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
+2 -1
View File
@@ -516,8 +516,9 @@ export function createPolicyUpdater(
if (message.persist) {
persistenceQueue = persistenceQueue.then(async () => {
try {
const workspacePoliciesDir = storage.getWorkspacePoliciesDir();
await fs.mkdir(workspacePoliciesDir, { recursive: true });
const policyFile = storage.getAutoSavedPolicyPath();
await fs.mkdir(path.dirname(policyFile), { recursive: true });
// Read existing file
let existingData: { rule?: TomlRule[] } = {};
+34 -9
View File
@@ -48,8 +48,14 @@ describe('createPolicyUpdater', () => {
it('should persist policy when persist flag is true', async () => {
createPolicyUpdater(policyEngine, messageBus, mockStorage);
const userPoliciesDir = '/mock/user/.gemini/policies';
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
const workspacePoliciesDir = '/mock/project/.gemini/policies';
const policyFile = path.join(
workspacePoliciesDir,
AUTO_SAVED_POLICY_FILENAME,
);
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
workspacePoliciesDir,
);
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
(fs.readFile as unknown as Mock).mockRejectedValue(
@@ -73,7 +79,8 @@ describe('createPolicyUpdater', () => {
// Wait for async operations (microtasks)
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fs.mkdir).toHaveBeenCalledWith(userPoliciesDir, {
expect(mockStorage.getWorkspacePoliciesDir).toHaveBeenCalled();
expect(fs.mkdir).toHaveBeenCalledWith(workspacePoliciesDir, {
recursive: true,
});
@@ -108,8 +115,14 @@ describe('createPolicyUpdater', () => {
it('should persist policy with commandPrefix when provided', async () => {
createPolicyUpdater(policyEngine, messageBus, mockStorage);
const userPoliciesDir = '/mock/user/.gemini/policies';
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
const workspacePoliciesDir = '/mock/project/.gemini/policies';
const policyFile = path.join(
workspacePoliciesDir,
AUTO_SAVED_POLICY_FILENAME,
);
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
workspacePoliciesDir,
);
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
(fs.readFile as unknown as Mock).mockRejectedValue(
@@ -155,8 +168,14 @@ describe('createPolicyUpdater', () => {
it('should persist policy with mcpName and toolName when provided', async () => {
createPolicyUpdater(policyEngine, messageBus, mockStorage);
const userPoliciesDir = '/mock/user/.gemini/policies';
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
const workspacePoliciesDir = '/mock/project/.gemini/policies';
const policyFile = path.join(
workspacePoliciesDir,
AUTO_SAVED_POLICY_FILENAME,
);
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
workspacePoliciesDir,
);
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
(fs.readFile as unknown as Mock).mockRejectedValue(
@@ -195,8 +214,14 @@ describe('createPolicyUpdater', () => {
it('should escape special characters in toolName and mcpName', async () => {
createPolicyUpdater(policyEngine, messageBus, mockStorage);
const userPoliciesDir = '/mock/user/.gemini/policies';
const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME);
const workspacePoliciesDir = '/mock/project/.gemini/policies';
const policyFile = path.join(
workspacePoliciesDir,
AUTO_SAVED_POLICY_FILENAME,
);
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
workspacePoliciesDir,
);
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile);
(fs.mkdir as unknown as Mock).mockResolvedValue(undefined);
(fs.readFile as unknown as Mock).mockRejectedValue(
@@ -50,8 +50,8 @@ describe('createPolicyUpdater', () => {
messageBus = new MessageBus(policyEngine);
mockStorage = new Storage('/mock/project');
vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(
'/mock/user/.gemini/policies/auto-saved.toml',
vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue(
'/mock/project/.gemini/policies',
);
});
+1 -1
View File
@@ -472,7 +472,7 @@ ${options.planModeToolsList}
## Rules
1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`${options.plansDir}/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval.
2. **Write Constraint:** ${formatToolName(WRITE_FILE_TOOL_NAME)} and ${formatToolName(EDIT_TOOL_NAME)} may ONLY be used to write .md plan files to \`${options.plansDir}/\`. They cannot modify source code.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use ${formatToolName(ASK_USER_TOOL_NAME)} to clarify. Use multi-select to offer flexibility and include detailed descriptions for each option to help the user understand the implications of their choice.
3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use ${formatToolName(ASK_USER_TOOL_NAME)} to clarify.
4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning.
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
@@ -859,7 +859,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: ask_user 1`] = `
{
"description": "Ask the user one or more questions to gather preferences, clarify requirements, or make decisions. When using this tool, prefer providing multiple-choice options with detailed descriptions and enable multi-select where appropriate to provide maximum flexibility.",
"description": "Ask the user one or more questions to gather preferences, clarify requirements, or make decisions.",
"name": "ask_user",
"parametersJsonSchema": {
"properties": {
@@ -558,7 +558,7 @@ The agent did not use the todo list because this task could be completed by a ti
ask_user: {
name: ASK_USER_TOOL_NAME,
description:
'Ask the user one or more questions to gather preferences, clarify requirements, or make decisions. When using this tool, prefer providing multiple-choice options with detailed descriptions and enable multi-select where appropriate to provide maximum flexibility.',
'Ask the user one or more questions to gather preferences, clarify requirements, or make decisions.',
parametersJsonSchema: {
type: 'object',
required: ['questions'],
@@ -1792,80 +1792,6 @@ describe('mcp-client', () => {
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBeUndefined();
});
it('should include extension settings with defined values in environment', async () => {
const mockedTransport = vi
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
await createTransport(
'test-server',
{
command: 'test-command',
extension: {
name: 'test-ext',
resolvedSettings: [
{
envVar: 'GEMINI_CLI_EXT_VAR',
value: 'defined-value',
sensitive: false,
name: 'ext-setting',
},
],
version: '',
isActive: false,
path: '',
contextFiles: [],
id: '',
},
},
false,
EMPTY_CONFIG,
);
const callArgs = mockedTransport.mock.calls[0][0];
expect(callArgs.env).toBeDefined();
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBe('defined-value');
});
it('should resolve environment variables in mcpServerConfig.env using extension settings', async () => {
const mockedTransport = vi
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
await createTransport(
'test-server',
{
command: 'test-command',
env: {
RESOLVED_VAR: '$GEMINI_CLI_EXT_VAR',
},
extension: {
name: 'test-ext',
resolvedSettings: [
{
envVar: 'GEMINI_CLI_EXT_VAR',
value: 'ext-value',
sensitive: false,
name: 'ext-setting',
},
],
version: '',
isActive: false,
path: '',
contextFiles: [],
id: '',
},
},
false,
EMPTY_CONFIG,
);
const callArgs = mockedTransport.mock.calls[0][0];
expect(callArgs.env).toBeDefined();
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBe('ext-value');
expect(callArgs.env!['RESOLVED_VAR']).toBe('ext-value');
});
it('should expand environment variables in mcpServerConfig.env and not redact them', async () => {
const mockedTransport = vi
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
+6 -53
View File
@@ -34,11 +34,7 @@ import {
ProgressNotificationSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { parse } from 'shell-quote';
import type {
Config,
MCPServerConfig,
GeminiCLIExtension,
} from '../config/config.js';
import type { Config, MCPServerConfig } from '../config/config.js';
import { AuthProviderType } from '../config/config.js';
import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js';
@@ -782,25 +778,15 @@ async function handleAutomaticOAuth(
*
* @param mcpServerConfig The MCP server configuration
* @param headers Additional headers
* @param sanitizationConfig Configuration for environment sanitization
*/
function createTransportRequestInit(
mcpServerConfig: MCPServerConfig,
headers: Record<string, string>,
sanitizationConfig: EnvironmentSanitizationConfig,
): RequestInit {
const extensionEnv = getExtensionEnvironment(mcpServerConfig.extension);
const expansionEnv = { ...process.env, ...extensionEnv };
const sanitizedEnv = sanitizeEnvironment(expansionEnv, {
...sanitizationConfig,
enableEnvironmentVariableRedaction: true,
});
const expandedHeaders: Record<string, string> = {};
if (mcpServerConfig.headers) {
for (const [key, value] of Object.entries(mcpServerConfig.headers)) {
expandedHeaders[key] = expandEnvVars(value, sanitizedEnv);
expandedHeaders[key] = expandEnvVars(value, process.env);
}
}
@@ -840,14 +826,12 @@ function createAuthProvider(
* @param mcpServerName The name of the MCP server
* @param mcpServerConfig The MCP server configuration
* @param accessToken The OAuth access token
* @param sanitizationConfig Configuration for environment sanitization
* @returns The transport with OAuth token, or null if creation fails
*/
async function createTransportWithOAuth(
mcpServerName: string,
mcpServerConfig: MCPServerConfig,
accessToken: string,
sanitizationConfig: EnvironmentSanitizationConfig,
): Promise<StreamableHTTPClientTransport | SSEClientTransport | null> {
try {
const headers: Record<string, string> = {
@@ -856,11 +840,7 @@ async function createTransportWithOAuth(
const transportOptions:
| StreamableHTTPClientTransportOptions
| SSEClientTransportOptions = {
requestInit: createTransportRequestInit(
mcpServerConfig,
headers,
sanitizationConfig,
),
requestInit: createTransportRequestInit(mcpServerConfig, headers),
};
return createUrlTransport(mcpServerName, mcpServerConfig, transportOptions);
@@ -1455,7 +1435,6 @@ async function showAuthRequiredMessage(serverName: string): Promise<never> {
* @param config The MCP server configuration
* @param accessToken The OAuth access token to use
* @param httpReturned404 Whether the HTTP transport returned 404 (indicating SSE-only server)
* @param sanitizationConfig Configuration for environment sanitization
*/
async function retryWithOAuth(
client: Client,
@@ -1463,7 +1442,6 @@ async function retryWithOAuth(
config: MCPServerConfig,
accessToken: string,
httpReturned404: boolean,
sanitizationConfig: EnvironmentSanitizationConfig,
): Promise<void> {
if (httpReturned404) {
// HTTP returned 404, only try SSE
@@ -1484,7 +1462,6 @@ async function retryWithOAuth(
serverName,
config,
accessToken,
sanitizationConfig,
);
if (!httpTransport) {
throw new Error(
@@ -1764,7 +1741,6 @@ export async function connectToMcpServer(
mcpServerConfig,
accessToken,
httpReturned404,
sanitizationConfig,
);
return mcpClient;
} else {
@@ -1837,7 +1813,6 @@ export async function connectToMcpServer(
mcpServerName,
mcpServerConfig,
accessToken,
sanitizationConfig,
);
if (!oauthTransport) {
throw new Error(
@@ -1985,11 +1960,7 @@ export async function createTransport(
const transportOptions:
| StreamableHTTPClientTransportOptions
| SSEClientTransportOptions = {
requestInit: createTransportRequestInit(
mcpServerConfig,
headers,
sanitizationConfig,
),
requestInit: createTransportRequestInit(mcpServerConfig, headers),
authProvider,
};
@@ -1997,11 +1968,8 @@ export async function createTransport(
}
if (mcpServerConfig.command) {
const extensionEnv = getExtensionEnvironment(mcpServerConfig.extension);
const expansionEnv = { ...process.env, ...extensionEnv };
// 1. Sanitize the base process environment to prevent unintended leaks of system-wide secrets.
const sanitizedEnv = sanitizeEnvironment(expansionEnv, {
const sanitizedEnv = sanitizeEnvironment(process.env, {
...sanitizationConfig,
enableEnvironmentVariableRedaction: true,
});
@@ -2009,7 +1977,6 @@ export async function createTransport(
const finalEnv: Record<string, string> = {
[GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
...extensionEnv,
};
for (const [key, value] of Object.entries(sanitizedEnv)) {
if (value !== undefined) {
@@ -2020,7 +1987,7 @@ export async function createTransport(
// Expand and merge explicit environment variables from the MCP configuration.
if (mcpServerConfig.env) {
for (const [key, value] of Object.entries(mcpServerConfig.env)) {
finalEnv[key] = expandEnvVars(value, expansionEnv);
finalEnv[key] = expandEnvVars(value, process.env);
}
}
@@ -2078,20 +2045,6 @@ interface NamedTool {
name?: string;
}
function getExtensionEnvironment(
extension?: GeminiCLIExtension,
): Record<string, string> {
const env: Record<string, string> = {};
if (extension?.resolvedSettings) {
for (const setting of extension.resolvedSettings) {
if (setting.value !== undefined) {
env[setting.envVar] = setting.value;
}
}
}
return env;
}
/** Visible for testing */
export function isEnabled(
funcDecl: NamedTool,