From 14dd07be00bb4cdf48b9da91ec362297040dd03c Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Fri, 27 Feb 2026 08:00:07 -0800 Subject: [PATCH 01/29] fix(cli): ensure dialogs stay scrolled to bottom in alternate buffer mode (#20527) --- .../ui/components/shared/Scrollable.test.tsx | 39 ++- .../src/ui/components/shared/Scrollable.tsx | 149 ++++++---- .../components/shared/ScrollableList.test.tsx | 273 ++++++++++++++++++ .../ui/components/shared/ScrollableList.tsx | 32 +- .../shared/VirtualizedList.test.tsx | 9 +- .../ui/components/shared/VirtualizedList.tsx | 261 ++++++++++------- 6 files changed, 571 insertions(+), 192 deletions(-) diff --git a/packages/cli/src/ui/components/shared/Scrollable.test.tsx b/packages/cli/src/ui/components/shared/Scrollable.test.tsx index db32a1a2e9..7772cdf22c 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.test.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.test.tsx @@ -6,20 +6,11 @@ import { renderWithProviders } from '../../../test-utils/render.js'; import { Scrollable } from './Scrollable.js'; -import { Text } from 'ink'; +import { Text, Box } from 'ink'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as ScrollProviderModule from '../../contexts/ScrollProvider.js'; import { act } from 'react'; - -vi.mock('ink', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getInnerHeight: vi.fn(() => 5), - getScrollHeight: vi.fn(() => 10), - getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 5 })), - }; -}); +import { waitFor } from '../../../test-utils/async.js'; vi.mock('../../hooks/useAnimatedScrollbar.js', () => ({ useAnimatedScrollbar: ( @@ -129,20 +120,26 @@ describe('', () => { , ); await waitUntilReady2(); - expect(capturedEntry.getScrollState().scrollTop).toBe(5); + await waitFor(() => { + 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) - expect(capturedEntry.getScrollState().scrollTop).toBe(3); + await waitFor(() => { + expect(capturedEntry?.getScrollState().scrollTop).toBe(3); + }); await act(async () => { - capturedEntry!.scrollBy(-2); + capturedEntry?.scrollBy(-2); + }); + await waitFor(() => { + expect(capturedEntry?.getScrollState().scrollTop).toBe(1); }); - expect(capturedEntry.getScrollState().scrollTop).toBe(1); unmount2(); }); @@ -191,10 +188,6 @@ describe('', () => { 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) => { @@ -206,7 +199,9 @@ describe('', () => { const { stdin, waitUntilReady, unmount } = renderWithProviders( - Content + + Content + , ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx index a830cbecfe..87ec6e72d6 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.tsx @@ -4,15 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { - useState, - useEffect, - useRef, - useLayoutEffect, - useCallback, - useMemo, -} from 'react'; -import { Box, getInnerHeight, getScrollHeight, type DOMElement } from 'ink'; +import type React from 'react'; +import { useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react'; +import { Box, ResizeObserver, type DOMElement } from 'ink'; import { useKeypress, type Key } from '../../hooks/useKeypress.js'; import { useScrollable } from '../../contexts/ScrollProvider.js'; import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js'; @@ -41,62 +35,101 @@ export const Scrollable: React.FC = ({ flexGrow, }) => { const [scrollTop, setScrollTop] = useState(0); - const ref = useRef(null); + const viewportRef = useRef(null); + const contentRef = useRef(null); const [size, setSize] = useState({ - innerHeight: 0, + innerHeight: typeof height === 'number' ? height : 0, scrollHeight: 0, }); const sizeRef = useRef(size); - useEffect(() => { + const scrollTopRef = useRef(scrollTop); + + useLayoutEffect(() => { 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(() => { - if (!ref.current) { - return; + scrollTopRef.current = scrollTop; + }, [scrollTop]); + + const viewportObserverRef = useRef(null); + const contentObserverRef = useRef(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; } - const innerHeight = Math.round(getInnerHeight(ref.current)); - const scrollHeight = Math.round(getScrollHeight(ref.current)); + }, []); - const isAtBottom = - scrollHeight > innerHeight && scrollTop >= scrollHeight - innerHeight - 1; + const contentRefCallback = useCallback( + (node: DOMElement | null) => { + contentObserverRef.current?.disconnect(); + contentRef.current = node; - if ( - size.innerHeight !== innerHeight || - size.scrollHeight !== scrollHeight - ) { - setSize({ innerHeight, scrollHeight }); - if (isAtBottom) { - setScrollTop(Math.max(0, scrollHeight - innerHeight)); + 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; } - } - - const childCountCurrent = React.Children.count(children); - if (scrollToBottom && childrenCountRef.current !== childCountCurrent) { - setScrollTop(Math.max(0, scrollHeight - innerHeight)); - } - childrenCountRef.current = childCountCurrent; - }); + }, + [scrollToBottom], + ); const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop); const scrollBy = useCallback( (delta: number) => { const { scrollHeight, innerHeight } = sizeRef.current; - const current = getScrollTop(); - const next = Math.min( - Math.max(0, current + delta), - Math.max(0, scrollHeight - innerHeight), - ); + 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; + } setPendingScrollTop(next); setScrollTop(next); }, - [sizeRef, getScrollTop, setPendingScrollTop], + [getScrollTop, setPendingScrollTop], ); const { scrollbarColor, flashScrollbar, scrollByWithAnimation } = @@ -107,10 +140,11 @@ export const Scrollable: React.FC = ({ 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 (scrollTop > 0) { + if (actualScrollTop > 0) { if (keyMatchers[Command.PAGE_UP](key)) { scrollByWithAnimation(-innerHeight); return true; @@ -123,7 +157,7 @@ export const Scrollable: React.FC = ({ // Only capture scroll-down events if there's room; // otherwise allow events to bubble. - if (scrollTop < maxScroll) { + if (actualScrollTop < maxScroll) { if (keyMatchers[Command.PAGE_DOWN](key)) { scrollByWithAnimation(innerHeight); return true; @@ -140,21 +174,21 @@ export const Scrollable: React.FC = ({ { isActive: hasFocus }, ); - const getScrollState = useCallback( - () => ({ - scrollTop: getScrollTop(), + const getScrollState = useCallback(() => { + const maxScroll = Math.max(0, size.scrollHeight - size.innerHeight); + return { + scrollTop: Math.min(getScrollTop(), maxScroll), 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: ref as React.RefObject, + ref: viewportRef as React.RefObject, getScrollState, scrollBy: scrollByWithAnimation, hasFocus: hasFocusCallback, @@ -167,7 +201,7 @@ export const Scrollable: React.FC = ({ return ( = ({ based on the children's content. It also adds a right padding to make room for the scrollbar. */} - + {children} diff --git a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx index 8b8c4e3fdf..1dd72b89a2 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx @@ -479,4 +479,277 @@ describe('ScrollableList Demo Behavior', () => { }); }); }); + + it('regression: remove last item and add 2 items when scrolled to bottom', async () => { + let listRef: ScrollableListRef | null = null; + let setItemsFunc: React.Dispatch> | null = + null; + + const TestComp = () => { + const [items, setItems] = useState( + Array.from({ length: 10 }, (_, i) => ({ + id: String(i), + title: `Item ${i}`, + })), + ); + useEffect(() => { + setItemsFunc = setItems; + }, []); + + return ( + + + + + { + listRef = ref; + }} + data={items} + renderItem={({ item }) => {item.title}} + estimatedItemHeight={() => 1} + keyExtractor={(item) => item.id} + hasFocus={true} + initialScrollIndex={Number.MAX_SAFE_INTEGER} + /> + + + + + ); + }; + + let result: ReturnType; + await act(async () => { + result = render(); + }); + + 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 | 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 ( + + {item.title} + {expanded && Expanded content} + + ); + }; + + const TestComp = () => { + // items array is stable + const [items] = useState(() => + Array.from({ length: 5 }, (_, i) => ({ + id: String(i), + title: `Item ${i}`, + })), + ); + + return ( + + + + + { + listRef = ref; + }} + data={items} + renderItem={({ item, index }) => ( + + )} + estimatedItemHeight={() => 1} + keyExtractor={(item) => item.id} + hasFocus={true} + initialScrollIndex={Number.MAX_SAFE_INTEGER} + /> + + + + + ); + }; + + let result: ReturnType; + await act(async () => { + result = render(); + }); + + 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 | null = null; + let setItemsFunc: React.Dispatch> | null = + null; + + const TestComp = () => { + // Items 1 to 5. Item 1 is very tall. + const [items, setItems] = useState( + Array.from({ length: 5 }, (_, i) => ({ + id: String(i + 1), + title: `Item ${i + 1}`, + })), + ); + useEffect(() => { + setItemsFunc = setItems; + }, []); + + return ( + + + + + { + listRef = ref; + }} + data={items} + renderItem={({ item }) => ( + + {item.title} + + )} + estimatedItemHeight={() => 2} + keyExtractor={(item) => item.id} + hasFocus={true} + initialScrollIndex={Number.MAX_SAFE_INTEGER} + /> + + + + + ); + }; + + let result: ReturnType; + await act(async () => { + result = render(); + }); + + 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(); + }); }); diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx index e51acd6446..b7085329a3 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -10,7 +10,7 @@ import { useImperativeHandle, useCallback, useMemo, - useEffect, + useLayoutEffect, } from 'react'; import type React from 'react'; import { @@ -105,7 +105,7 @@ function ScrollableList( smoothScrollState.current.active = false; }, []); - useEffect(() => stopSmoothScroll, [stopSmoothScroll]); + useLayoutEffect(() => stopSmoothScroll, [stopSmoothScroll]); const smoothScrollTo = useCallback( ( @@ -120,15 +120,19 @@ function ScrollableList( innerHeight: 0, }; const { - scrollTop: startScrollTop, + scrollTop: rawStartScrollTop, 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) { + if ( + targetScrollTop === SCROLL_TO_ITEM_END || + targetScrollTop >= maxScrollTop + ) { effectiveTarget = maxScrollTop; } @@ -138,8 +142,11 @@ function ScrollableList( ); if (duration === 0) { - if (targetScrollTop === SCROLL_TO_ITEM_END) { - virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END); + if ( + targetScrollTop === SCROLL_TO_ITEM_END || + targetScrollTop >= maxScrollTop + ) { + virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER); } else { virtualizedListRef.current?.scrollTo(Math.round(clampedTarget)); } @@ -168,8 +175,11 @@ function ScrollableList( ease; if (progress >= 1) { - if (targetScrollTop === SCROLL_TO_ITEM_END) { - virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END); + if ( + targetScrollTop === SCROLL_TO_ITEM_END || + targetScrollTop >= maxScrollTop + ) { + virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER); } else { virtualizedListRef.current?.scrollTo(Math.round(current)); } @@ -200,9 +210,13 @@ function ScrollableList( ) { 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 - : scrollState.scrollTop; + : Math.min(scrollState.scrollTop, maxScroll); const innerHeight = scrollState.innerHeight; smoothScrollTo(current + direction * innerHeight); return true; diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx index 0edc323d38..60b8bfc421 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx @@ -5,6 +5,7 @@ */ 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 { @@ -275,9 +276,11 @@ describe('', () => { await waitUntilReady(); // Now Item 0 is 1px, so Items 1-9 should also be visible to fill 10px - expect(lastFrame()).toContain('Item 0'); - expect(lastFrame()).toContain('Item 1'); - expect(lastFrame()).toContain('Item 9'); + await waitFor(() => { + expect(lastFrame()).toContain('Item 0'); + expect(lastFrame()).toContain('Item 1'); + expect(lastFrame()).toContain('Item 9'); + }); unmount(); }); diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx index 98e45a695e..669b1bc035 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -10,7 +10,6 @@ import { useLayoutEffect, forwardRef, useImperativeHandle, - useEffect, useMemo, useCallback, } from 'react'; @@ -19,7 +18,7 @@ import { theme } from '../../semantic-colors.js'; import { useBatchedScroll } from '../../hooks/useBatchedScroll.js'; import { useUIState } from '../../contexts/UIStateContext.js'; -import { type DOMElement, measureElement, Box } from 'ink'; +import { type DOMElement, Box, ResizeObserver } from 'ink'; export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER; @@ -81,7 +80,7 @@ function VirtualizedList( } = props; const { copyModeEnabled } = useUIState(); const dataRef = useRef(data); - useEffect(() => { + useLayoutEffect(() => { dataRef.current = data; }, [data]); @@ -108,6 +107,7 @@ function VirtualizedList( return { index: 0, offset: 0 }; }); + const [isStickingToBottom, setIsStickingToBottom] = useState(() => { const scrollToEnd = initialScrollIndex === SCROLL_TO_ITEM_END || @@ -116,73 +116,75 @@ function VirtualizedList( initialScrollOffsetInIndex === SCROLL_TO_ITEM_END); return scrollToEnd; }); - const containerRef = useRef(null); + + const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(0); const itemRefs = useRef>([]); - const [heights, setHeights] = useState([]); + const [heights, setHeights] = useState>({}); const isInitialScrollSet = useRef(false); + const containerObserverRef = useRef(null); + const nodeToKeyRef = useRef(new WeakMap()); + + 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 | 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 height = heights[i] ?? estimatedItemHeight(i); + const key = keyExtractor(data[i], i); + const height = heights[key] ?? estimatedItemHeight(i); totalHeight += height; offsets.push(totalHeight); } return { totalHeight, offsets }; - }, [heights, data, estimatedItemHeight]); + }, [heights, data, estimatedItemHeight, keyExtractor]); - 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 scrollableContainerHeight = containerHeight; const getAnchorForScrollTop = useCallback( ( @@ -199,23 +201,36 @@ function VirtualizedList( [], ); - const scrollTop = useMemo(() => { + const actualScrollTop = useMemo(() => { const offset = offsets[scrollAnchor.index]; if (typeof offset !== 'number') { return 0; } if (scrollAnchor.offset === SCROLL_TO_ITEM_END) { - const itemHeight = heights[scrollAnchor.index] ?? 0; + const item = data[scrollAnchor.index]; + const key = item ? keyExtractor(item, scrollAnchor.index) : ''; + const itemHeight = heights[key] ?? 0; return offset + itemHeight - scrollableContainerHeight; } return offset + scrollAnchor.offset; - }, [scrollAnchor, offsets, heights, scrollableContainerHeight]); + }, [ + scrollAnchor, + offsets, + heights, + scrollableContainerHeight, + data, + keyExtractor, + ]); + + const scrollTop = isStickingToBottom + ? Number.MAX_SAFE_INTEGER + : actualScrollTop; const prevDataLength = useRef(data.length); const prevTotalHeight = useRef(totalHeight); - const prevScrollTop = useRef(scrollTop); + const prevScrollTop = useRef(actualScrollTop); const prevContainerHeight = useRef(scrollableContainerHeight); useLayoutEffect(() => { @@ -226,9 +241,7 @@ function VirtualizedList( prevTotalHeight.current - prevContainerHeight.current - 1; const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels; - // If the user was at the bottom, they are now sticking. This handles - // manually scrolling back to the bottom. - if (wasAtBottom && scrollTop >= prevScrollTop.current) { + if (wasAtBottom && actualScrollTop >= prevScrollTop.current) { setIsStickingToBottom(true); } @@ -236,9 +249,6 @@ function VirtualizedList( 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) @@ -247,34 +257,28 @@ function VirtualizedList( 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); } - } - // 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 ( + } else if ( (scrollAnchor.index >= data.length || - scrollTop > totalHeight - scrollableContainerHeight) && + actualScrollTop > 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 = scrollTop; + prevScrollTop.current = actualScrollTop; prevContainerHeight.current = scrollableContainerHeight; }, [ data.length, totalHeight, - scrollTop, + actualScrollTop, scrollableContainerHeight, scrollAnchor.index, getAnchorForScrollTop, @@ -334,10 +338,10 @@ function VirtualizedList( const startIndex = Math.max( 0, - findLastIndex(offsets, (offset) => offset <= scrollTop) - 1, + findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1, ); const endIndexOffset = offsets.findIndex( - (offset) => offset > scrollTop + scrollableContainerHeight, + (offset) => offset > actualScrollTop + scrollableContainerHeight, ); const endIndex = endIndexOffset === -1 @@ -348,6 +352,32 @@ function VirtualizedList( const bottomSpacerHeight = totalHeight - (offsets[endIndex + 1] ?? totalHeight); + // Maintain a stable set of observed nodes using useLayoutEffect + const observedNodes = useRef>(new Set()); + useLayoutEffect(() => { + const currentNodes = new Set(); + 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]; @@ -356,6 +386,8 @@ function VirtualizedList( { itemRefs.current[i] = el; }} @@ -376,27 +408,39 @@ function VirtualizedList( setIsStickingToBottom(false); } const currentScrollTop = getScrollTop(); - const newScrollTop = Math.max( - 0, - Math.min( - totalHeight - scrollableContainerHeight, - currentScrollTop + delta, - ), - ); + 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(newScrollTop, offsets)); + setScrollAnchor( + getAnchorForScrollTop(Math.min(newScrollTop, maxScroll), offsets), + ); }, scrollTo: (offset: number) => { - setIsStickingToBottom(false); - const newScrollTop = Math.max( - 0, - Math.min(totalHeight - scrollableContainerHeight, offset), - ); - setPendingScrollTop(newScrollTop); - setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets)); + 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)); + } }, scrollToEnd: () => { setIsStickingToBottom(true); + setPendingScrollTop(Number.MAX_SAFE_INTEGER); if (data.length > 0) { setScrollAnchor({ index: data.length - 1, @@ -416,10 +460,14 @@ function VirtualizedList( setIsStickingToBottom(false); const offset = offsets[index]; if (offset !== undefined) { + const maxScroll = Math.max( + 0, + totalHeight - scrollableContainerHeight, + ); const newScrollTop = Math.max( 0, Math.min( - totalHeight - scrollableContainerHeight, + maxScroll, offset - viewPosition * scrollableContainerHeight + viewOffset, ), ); @@ -441,10 +489,14 @@ function VirtualizedList( if (index !== -1) { const offset = offsets[index]; if (offset !== undefined) { + const maxScroll = Math.max( + 0, + totalHeight - scrollableContainerHeight, + ); const newScrollTop = Math.max( 0, Math.min( - totalHeight - scrollableContainerHeight, + maxScroll, offset - viewPosition * scrollableContainerHeight + viewOffset, ), ); @@ -454,11 +506,14 @@ function VirtualizedList( } }, getScrollIndex: () => scrollAnchor.index, - getScrollState: () => ({ - scrollTop: getScrollTop(), - scrollHeight: totalHeight, - innerHeight: containerHeight, - }), + getScrollState: () => { + const maxScroll = Math.max(0, totalHeight - containerHeight); + return { + scrollTop: Math.min(getScrollTop(), maxScroll), + scrollHeight: totalHeight, + innerHeight: containerHeight, + }; + }, }), [ offsets, @@ -475,7 +530,7 @@ function VirtualizedList( return ( ( flexShrink={0} width="100%" flexDirection="column" - marginTop={copyModeEnabled ? -scrollTop : 0} + marginTop={copyModeEnabled ? -actualScrollTop : 0} > {renderedItems} From 32e777f8387f6bcb6f6b2eafa353e861a2e1e47c Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Fri, 27 Feb 2026 08:03:36 -0800 Subject: [PATCH 02/29] fix(core): revert auto-save of policies to user space (#20531) --- packages/core/src/config/storage.ts | 5 +-- packages/core/src/policy/config.ts | 3 +- packages/core/src/policy/persistence.test.ts | 43 ++++--------------- .../core/src/policy/policy-updater.test.ts | 4 +- 4 files changed, 13 insertions(+), 42 deletions(-) diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index e8530887b3..10e88543ba 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -169,10 +169,7 @@ export class Storage { } getAutoSavedPolicyPath(): string { - return path.join( - this.getWorkspacePoliciesDir(), - AUTO_SAVED_POLICY_FILENAME, - ); + return path.join(Storage.getUserPoliciesDir(), AUTO_SAVED_POLICY_FILENAME); } ensureProjectTempDirExists(): void { diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index 800006e27e..6cdfc199d2 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -516,9 +516,8 @@ 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[] } = {}; diff --git a/packages/core/src/policy/persistence.test.ts b/packages/core/src/policy/persistence.test.ts index 43f52a956d..c5a71fdd93 100644 --- a/packages/core/src/policy/persistence.test.ts +++ b/packages/core/src/policy/persistence.test.ts @@ -48,14 +48,8 @@ describe('createPolicyUpdater', () => { it('should persist policy when persist flag is true', async () => { createPolicyUpdater(policyEngine, messageBus, mockStorage); - const workspacePoliciesDir = '/mock/project/.gemini/policies'; - const policyFile = path.join( - workspacePoliciesDir, - AUTO_SAVED_POLICY_FILENAME, - ); - vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( - workspacePoliciesDir, - ); + const userPoliciesDir = '/mock/user/.gemini/policies'; + const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME); vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); (fs.mkdir as unknown as Mock).mockResolvedValue(undefined); (fs.readFile as unknown as Mock).mockRejectedValue( @@ -79,8 +73,7 @@ describe('createPolicyUpdater', () => { // Wait for async operations (microtasks) await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockStorage.getWorkspacePoliciesDir).toHaveBeenCalled(); - expect(fs.mkdir).toHaveBeenCalledWith(workspacePoliciesDir, { + expect(fs.mkdir).toHaveBeenCalledWith(userPoliciesDir, { recursive: true, }); @@ -115,14 +108,8 @@ describe('createPolicyUpdater', () => { it('should persist policy with commandPrefix when provided', async () => { createPolicyUpdater(policyEngine, messageBus, mockStorage); - const workspacePoliciesDir = '/mock/project/.gemini/policies'; - const policyFile = path.join( - workspacePoliciesDir, - AUTO_SAVED_POLICY_FILENAME, - ); - vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( - workspacePoliciesDir, - ); + const userPoliciesDir = '/mock/user/.gemini/policies'; + const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME); vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); (fs.mkdir as unknown as Mock).mockResolvedValue(undefined); (fs.readFile as unknown as Mock).mockRejectedValue( @@ -168,14 +155,8 @@ describe('createPolicyUpdater', () => { it('should persist policy with mcpName and toolName when provided', async () => { createPolicyUpdater(policyEngine, messageBus, mockStorage); - const workspacePoliciesDir = '/mock/project/.gemini/policies'; - const policyFile = path.join( - workspacePoliciesDir, - AUTO_SAVED_POLICY_FILENAME, - ); - vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( - workspacePoliciesDir, - ); + const userPoliciesDir = '/mock/user/.gemini/policies'; + const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME); vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); (fs.mkdir as unknown as Mock).mockResolvedValue(undefined); (fs.readFile as unknown as Mock).mockRejectedValue( @@ -214,14 +195,8 @@ describe('createPolicyUpdater', () => { it('should escape special characters in toolName and mcpName', async () => { createPolicyUpdater(policyEngine, messageBus, mockStorage); - const workspacePoliciesDir = '/mock/project/.gemini/policies'; - const policyFile = path.join( - workspacePoliciesDir, - AUTO_SAVED_POLICY_FILENAME, - ); - vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( - workspacePoliciesDir, - ); + const userPoliciesDir = '/mock/user/.gemini/policies'; + const policyFile = path.join(userPoliciesDir, AUTO_SAVED_POLICY_FILENAME); vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); (fs.mkdir as unknown as Mock).mockResolvedValue(undefined); (fs.readFile as unknown as Mock).mockRejectedValue( diff --git a/packages/core/src/policy/policy-updater.test.ts b/packages/core/src/policy/policy-updater.test.ts index 40780a1850..3037667949 100644 --- a/packages/core/src/policy/policy-updater.test.ts +++ b/packages/core/src/policy/policy-updater.test.ts @@ -50,8 +50,8 @@ describe('createPolicyUpdater', () => { messageBus = new MessageBus(policyEngine); mockStorage = new Storage('/mock/project'); - vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( - '/mock/project/.gemini/policies', + vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue( + '/mock/user/.gemini/policies/auto-saved.toml', ); }); From 514d43104954d8d3fd5b87210d9c3869a0131808 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Fri, 27 Feb 2026 16:48:46 +0000 Subject: [PATCH 03/29] Demote unreliable test. (#20571) --- evals/validation_fidelity.eval.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evals/validation_fidelity.eval.ts b/evals/validation_fidelity.eval.ts index d8f571773d..8cfb4f6626 100644 --- a/evals/validation_fidelity.eval.ts +++ b/evals/validation_fidelity.eval.ts @@ -8,7 +8,7 @@ import { describe, expect } from 'vitest'; import { evalTest } from './test-helper.js'; describe('validation_fidelity', () => { - evalTest('ALWAYS_PASSES', { + evalTest('USUALLY_PASSES', { name: 'should perform exhaustive validation autonomously when guided by system instructions', files: { 'src/types.ts': ` From e709789067e833823ce3528160806c30d40a8338 Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Fri, 27 Feb 2026 11:52:37 -0500 Subject: [PATCH 04/29] fix(core): handle optional response fields from code assist API (#20345) --- .../cli/src/ui/hooks/usePrivacySettings.ts | 15 +++++++++-- .../core/src/code_assist/converter.test.ts | 8 +++--- packages/core/src/code_assist/converter.ts | 14 ++++++++--- packages/core/src/code_assist/setup.ts | 25 ++++++++++++++++--- packages/core/src/code_assist/types.ts | 20 +++++++-------- 5 files changed, 58 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/ui/hooks/usePrivacySettings.ts b/packages/cli/src/ui/hooks/usePrivacySettings.ts index 64a9673812..7bf5a5ff1b 100644 --- a/packages/cli/src/ui/hooks/usePrivacySettings.ts +++ b/packages/cli/src/ui/hooks/usePrivacySettings.ts @@ -10,6 +10,7 @@ import { type CodeAssistServer, UserTierId, getCodeAssistServer, + debugLogger, } from '@google/gemini-cli-core'; export interface PrivacyState { @@ -103,7 +104,12 @@ async function getRemoteDataCollectionOptIn( ): Promise { try { const resp = await server.getCodeAssistGlobalUserSetting(); - return resp.freeTierDataCollectionOptin; + if (resp.freeTierDataCollectionOptin === undefined) { + debugLogger.warn( + 'Warning: Code Assist API did not return freeTierDataCollectionOptin. Defaulting to true.', + ); + } + return resp.freeTierDataCollectionOptin ?? true; } catch (error: unknown) { if (error && typeof error === 'object' && 'response' in error) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion @@ -128,5 +134,10 @@ async function setRemoteDataCollectionOptIn( cloudaicompanionProject: server.projectId, freeTierDataCollectionOptin: optIn, }); - return resp.freeTierDataCollectionOptin; + if (resp.freeTierDataCollectionOptin === undefined) { + debugLogger.warn( + `Warning: Code Assist API did not return freeTierDataCollectionOptin. Defaulting to ${optIn}.`, + ); + } + return resp.freeTierDataCollectionOptin ?? optIn; } diff --git a/packages/core/src/code_assist/converter.test.ts b/packages/core/src/code_assist/converter.test.ts index 21fecec547..674bbaf70e 100644 --- a/packages/core/src/code_assist/converter.test.ts +++ b/packages/core/src/code_assist/converter.test.ts @@ -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, ); }); diff --git a/packages/core/src/code_assist/converter.ts b/packages/core/src/code_assist/converter.ts index 1d41101f31..fc163a8f02 100644 --- a/packages/core/src/code_assist/converter.ts +++ b/packages/core/src/code_assist/converter.ts @@ -27,6 +27,7 @@ import type { ToolConfig, } from '@google/genai'; import { GenerateContentResponse } from '@google/genai'; +import { debugLogger } from '../utils/debugLogger.js'; export interface CAGenerateContentRequest { model: string; @@ -72,12 +73,12 @@ interface VertexGenerationConfig { } export interface CaGenerateContentResponse { - response: VertexGenerateContentResponse; + response?: VertexGenerateContentResponse; traceId?: string; } interface VertexGenerateContentResponse { - candidates: Candidate[]; + candidates?: Candidate[]; automaticFunctionCallingHistory?: Content[]; promptFeedback?: GenerateContentResponsePromptFeedback; usageMetadata?: GenerateContentResponseUsageMetadata; @@ -94,7 +95,7 @@ interface VertexCountTokenRequest { } export interface CaCountTokenResponse { - totalTokens: number; + totalTokens?: number; } export function toCountTokenRequest( @@ -111,8 +112,13 @@ 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, + totalTokens: res.totalTokens ?? 0, }; } diff --git a/packages/core/src/code_assist/setup.ts b/packages/core/src/code_assist/setup.ts index 895fabb6bc..dce96b9cdd 100644 --- a/packages/core/src/code_assist/setup.ts +++ b/packages/core/src/code_assist/setup.ts @@ -18,6 +18,7 @@ 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() { @@ -130,11 +131,20 @@ 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, + userTier: + loadRes.paidTier?.id ?? + loadRes.currentTier.id ?? + UserTierId.STANDARD, userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name, }; } @@ -144,13 +154,20 @@ export async function setupUser( } return { projectId: loadRes.cloudaicompanionProject, - userTier: loadRes.paidTier?.id ?? loadRes.currentTier.id, + userTier: + loadRes.paidTier?.id ?? loadRes.currentTier.id ?? UserTierId.STANDARD, 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. @@ -183,7 +200,7 @@ export async function setupUser( if (projectId) { return { projectId, - userTier: tier.id, + userTier: tier.id ?? UserTierId.STANDARD, userTierName: tier.name, }; } @@ -193,7 +210,7 @@ export async function setupUser( return { projectId: lroRes.response.cloudaicompanionProject.id, - userTier: tier.id, + userTier: tier.id ?? UserTierId.STANDARD, userTierName: tier.name, }; } diff --git a/packages/core/src/code_assist/types.ts b/packages/core/src/code_assist/types.ts index 0e2f353aa3..79932efc02 100644 --- a/packages/core/src/code_assist/types.ts +++ b/packages/core/src/code_assist/types.ts @@ -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; } /** From 3b2632fe40c763ee1904bb7281c41ead230563e1 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Fri, 27 Feb 2026 09:11:13 -0800 Subject: [PATCH 05/29] fix(cli): keep thought summary when loading phrases are off (#20497) Co-authored-by: Jacob Richman --- packages/cli/src/ui/components/Composer.test.tsx | 4 ++-- packages/cli/src/ui/components/Composer.tsx | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 330e615cfa..999b1531f9 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -402,7 +402,7 @@ describe('Composer', () => { expect(output).not.toContain('ShortcutsHint'); }); - it('renders LoadingIndicator without thought when loadingPhrases is off', async () => { + it('renders LoadingIndicator with thought when loadingPhrases is off', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { subject: 'Hidden', description: 'Should not show' }, @@ -415,7 +415,7 @@ describe('Composer', () => { const output = lastFrame(); expect(output).toContain('LoadingIndicator'); - expect(output).not.toContain('Should not show'); + expect(output).toContain('LoadingIndicator: Hidden'); }); it('does not render LoadingIndicator when waiting for confirmation', async () => { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 98a465fa39..51c879e772 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -229,8 +229,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { inline thought={ uiState.streamingState === - StreamingState.WaitingForConfirmation || - settings.merged.ui.loadingPhrases === 'off' + StreamingState.WaitingForConfirmation ? undefined : uiState.thought } @@ -273,8 +272,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { inline thought={ uiState.streamingState === - StreamingState.WaitingForConfirmation || - settings.merged.ui.loadingPhrases === 'off' + StreamingState.WaitingForConfirmation ? undefined : uiState.thought } From 59c0e73718476608e701bd9fff9370d4fdcad722 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Fri, 27 Feb 2026 09:25:16 -0800 Subject: [PATCH 06/29] feat(cli): add temporary flag to disable workspace policies (#20523) --- packages/cli/src/config/policy.test.ts | 33 ++++++++++++++++++- packages/cli/src/config/policy.ts | 16 ++++++++- .../src/config/workspace-policy-cli.test.ts | 1 + 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts index 10d53e56ef..9baccd3359 100644 --- a/packages/cli/src/config/policy.test.ts +++ b/packages/cli/src/config/policy.test.ts @@ -12,6 +12,8 @@ import { resolveWorkspacePolicyState, autoAcceptWorkspacePolicies, setAutoAcceptWorkspacePolicies, + disableWorkspacePolicies, + setDisableWorkspacePolicies, } from './policy.js'; import { writeToStderr } from '@google/gemini-cli-core'; @@ -45,6 +47,9 @@ describe('resolveWorkspacePolicyState', () => { fs.mkdirSync(workspaceDir); policiesDir = path.join(workspaceDir, '.gemini', 'policies'); + // Enable policies for these tests to verify loading logic + setDisableWorkspacePolicies(false); + vi.clearAllMocks(); }); @@ -67,6 +72,13 @@ 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 }); @@ -188,7 +200,26 @@ describe('resolveWorkspacePolicyState', () => { expect(result.policyUpdateConfirmationRequest).toBeUndefined(); }); - it('should not return workspace policies if cwd is a symlink to the home directory', async () => { + 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 () => { const policiesDir = path.join(tempDir, '.gemini', 'policies'); fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 6ce44020f5..bc22c928f8 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -35,6 +35,20 @@ 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, @@ -81,7 +95,7 @@ export async function resolveWorkspacePolicyState(options: { | PolicyUpdateConfirmationRequest | undefined; - if (trustedFolder) { + if (trustedFolder && !disableWorkspacePolicies) { const storage = new Storage(cwd); // If we are in the home directory (or rather, our target Gemini dir is the global one), diff --git a/packages/cli/src/config/workspace-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts index a7ab9d69b1..d0d98a5a31 100644 --- a/packages/cli/src/config/workspace-policy-cli.test.ts +++ b/packages/cli/src/config/workspace-policy-cli.test.ts @@ -54,6 +54,7 @@ describe('Workspace-Level Policy CLI Integration', () => { beforeEach(() => { vi.clearAllMocks(); + Policy.setDisableWorkspacePolicies(false); // Default to MATCH for existing tests mockCheckIntegrity.mockResolvedValue({ status: 'match', From 7a1f2f3288fb1180fbf7e7ccce9ef4153fb9102f Mon Sep 17 00:00:00 2001 From: DeWitt Clinton Date: Fri, 27 Feb 2026 09:40:09 -0800 Subject: [PATCH 07/29] Disable expensive and scheduled workflows on personal forks (#20449) --- .github/workflows/chained_e2e.yml | 23 ++++++++++--------- .github/workflows/ci.yml | 16 +++++++------ .github/workflows/deflake.yml | 3 +++ .github/workflows/docs-page-action.yml | 4 ++-- .github/workflows/docs-rebuild.yml | 1 + .github/workflows/evals-nightly.yml | 3 ++- .../gemini-scheduled-stale-issue-closer.yml | 1 + .../workflows/label-backlog-child-issues.yml | 4 ++-- .github/workflows/label-workstream-rollup.yml | 1 + .github/workflows/release-change-tags.yml | 1 + .github/workflows/release-manual.yml | 1 + .github/workflows/release-notes.yml | 1 + .github/workflows/release-rollback.yml | 1 + .github/workflows/release-sandbox.yml | 1 + .github/workflows/smoke-test.yml | 1 + .github/workflows/trigger_e2e.yml | 2 ++ .github/workflows/verify-release.yml | 1 + 17 files changed, 42 insertions(+), 23 deletions(-) diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml index 05b1fb0f1d..7d13a23938 100644 --- a/.github/workflows/chained_e2e.yml +++ b/.github/workflows/chained_e2e.yml @@ -31,6 +31,7 @@ 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: @@ -42,7 +43,7 @@ jobs: download_repo_name: runs-on: 'gemini-cli-ubuntu-16-core' - if: "${{github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run'}}" + if: "github.repository == 'google-gemini/gemini-cli' && (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 }}' @@ -91,7 +92,7 @@ jobs: name: 'Parse run context' runs-on: 'gemini-cli-ubuntu-16-core' needs: 'download_repo_name' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" outputs: repository: '${{ steps.set_context.outputs.REPO }}' sha: '${{ steps.set_context.outputs.SHA }}' @@ -111,11 +112,11 @@ jobs: permissions: 'write-all' needs: - 'parse_run_context' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" steps: - name: 'Set pending status' uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" with: allowForks: 'true' repo: '${{ github.repository }}' @@ -131,7 +132,7 @@ jobs: - 'parse_run_context' runs-on: 'gemini-cli-ubuntu-16-core' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') strategy: fail-fast: false matrix: @@ -184,7 +185,7 @@ jobs: - 'parse_run_context' runs-on: 'macos-latest' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && 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 @@ -222,7 +223,7 @@ jobs: - 'merge_queue_skipper' - 'parse_run_context' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') runs-on: 'gemini-cli-windows-16-core' steps: - name: 'Checkout' @@ -282,7 +283,7 @@ jobs: - 'parse_run_context' runs-on: 'gemini-cli-ubuntu-16-core' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && 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 @@ -309,7 +310,7 @@ jobs: e2e: name: 'E2E' if: | - always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') needs: - 'e2e_linux' - 'e2e_mac' @@ -337,14 +338,14 @@ jobs: set_workflow_status: runs-on: 'gemini-cli-ubuntu-16-core' permissions: 'write-all' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && 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: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" with: allowForks: 'true' repo: '${{ github.repository }}' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 999eb778c4..a358ad8b07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ 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: @@ -49,7 +50,7 @@ jobs: name: 'Lint' runs-on: 'gemini-cli-ubuntu-16-core' needs: 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" env: GEMINI_LINT_TEMP_DIR: '${{ github.workspace }}/.gemini-linters' steps: @@ -116,6 +117,7 @@ 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 @@ -129,7 +131,7 @@ jobs: runs-on: 'gemini-cli-ubuntu-16-core' needs: - 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" permissions: contents: 'read' checks: 'write' @@ -216,7 +218,7 @@ jobs: runs-on: 'macos-latest' needs: - 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" permissions: contents: 'read' checks: 'write' @@ -311,7 +313,7 @@ jobs: name: 'CodeQL' runs-on: 'gemini-cli-ubuntu-16-core' needs: 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" permissions: actions: 'read' contents: 'read' @@ -334,7 +336,7 @@ jobs: bundle_size: name: 'Check Bundle Size' needs: 'merge_queue_skipper' - if: "${{github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'" runs-on: 'gemini-cli-ubuntu-16-core' permissions: contents: 'read' # For checkout @@ -359,7 +361,7 @@ jobs: name: 'Slow Test - Win - ${{ matrix.shard }}' runs-on: 'gemini-cli-windows-16-core' needs: 'merge_queue_skipper' - if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}" + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" timeout-minutes: 60 strategy: matrix: @@ -451,7 +453,7 @@ jobs: ci: name: 'CI' - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" needs: - 'lint' - 'link_checker' diff --git a/.github/workflows/deflake.yml b/.github/workflows/deflake.yml index a0eb51a7f4..fbb3e2d8d7 100644 --- a/.github/workflows/deflake.yml +++ b/.github/workflows/deflake.yml @@ -27,6 +27,7 @@ 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: @@ -77,6 +78,7 @@ 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 @@ -114,6 +116,7 @@ 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' diff --git a/.github/workflows/docs-page-action.yml b/.github/workflows/docs-page-action.yml index 2d485278ce..be807c7c36 100644 --- a/.github/workflows/docs-page-action.yml +++ b/.github/workflows/docs-page-action.yml @@ -19,8 +19,7 @@ concurrency: jobs: build: - if: |- - ${{ !contains(github.ref_name, 'nightly') }} + if: "github.repository == 'google-gemini/gemini-cli' && !contains(github.ref_name, 'nightly')" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' @@ -39,6 +38,7 @@ jobs: uses: 'actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa' # ratchet:actions/upload-pages-artifact@v3 deploy: + if: "github.repository == 'google-gemini/gemini-cli'" environment: name: 'github-pages' url: '${{ steps.deployment.outputs.page_url }}' diff --git a/.github/workflows/docs-rebuild.yml b/.github/workflows/docs-rebuild.yml index ac41819f02..a4e2c65973 100644 --- a/.github/workflows/docs-rebuild.yml +++ b/.github/workflows/docs-rebuild.yml @@ -7,6 +7,7 @@ on: - 'docs/**' jobs: trigger-rebuild: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' steps: - name: 'Trigger rebuild' diff --git a/.github/workflows/evals-nightly.yml b/.github/workflows/evals-nightly.yml index 1ed9448c03..c5b3709c75 100644 --- a/.github/workflows/evals-nightly.yml +++ b/.github/workflows/evals-nightly.yml @@ -23,6 +23,7 @@ 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: @@ -85,7 +86,7 @@ jobs: aggregate-results: name: 'Aggregate Results' needs: ['evals'] - if: 'always()' + if: "github.repository == 'google-gemini/gemini-cli' && always()" runs-on: 'gemini-cli-ubuntu-16-core' steps: - name: 'Checkout' diff --git a/.github/workflows/gemini-scheduled-stale-issue-closer.yml b/.github/workflows/gemini-scheduled-stale-issue-closer.yml index c7aef65a73..2b7b163d88 100644 --- a/.github/workflows/gemini-scheduled-stale-issue-closer.yml +++ b/.github/workflows/gemini-scheduled-stale-issue-closer.yml @@ -21,6 +21,7 @@ defaults: jobs: close-stale-issues: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: issues: 'write' diff --git a/.github/workflows/label-backlog-child-issues.yml b/.github/workflows/label-backlog-child-issues.yml index b11f509f80..a819bf4e71 100644 --- a/.github/workflows/label-backlog-child-issues.yml +++ b/.github/workflows/label-backlog-child-issues.yml @@ -14,7 +14,7 @@ permissions: jobs: # Event-based: Quick reaction to new/edited issues in THIS repo labeler: - if: "github.event_name == 'issues'" + if: "github.repository == 'google-gemini/gemini-cli' && 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.event_name == 'schedule' || github.event_name == 'workflow_dispatch'" + if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')" runs-on: 'ubuntu-latest' steps: - name: 'Checkout' diff --git a/.github/workflows/label-workstream-rollup.yml b/.github/workflows/label-workstream-rollup.yml index 35840cfe6f..97d699d09b 100644 --- a/.github/workflows/label-workstream-rollup.yml +++ b/.github/workflows/label-workstream-rollup.yml @@ -9,6 +9,7 @@ on: jobs: labeler: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: issues: 'write' diff --git a/.github/workflows/release-change-tags.yml b/.github/workflows/release-change-tags.yml index 6184850677..c7c3f3f2d2 100644 --- a/.github/workflows/release-change-tags.yml +++ b/.github/workflows/release-change-tags.yml @@ -32,6 +32,7 @@ on: jobs: change-tags: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: diff --git a/.github/workflows/release-manual.yml b/.github/workflows/release-manual.yml index c9d2290a1c..f03bd52127 100644 --- a/.github/workflows/release-manual.yml +++ b/.github/workflows/release-manual.yml @@ -47,6 +47,7 @@ on: jobs: release: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' environment: "${{ github.event.inputs.environment || 'prod' }}" permissions: diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index 8a681dadf6..f746e65c2e 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -22,6 +22,7 @@ on: jobs: generate-release-notes: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'write' diff --git a/.github/workflows/release-rollback.yml b/.github/workflows/release-rollback.yml index 8840b65721..db91457b1a 100644 --- a/.github/workflows/release-rollback.yml +++ b/.github/workflows/release-rollback.yml @@ -42,6 +42,7 @@ on: jobs: change-tags: + if: "github.repository == 'google-gemini/gemini-cli'" environment: "${{ github.event.inputs.environment || 'prod' }}" runs-on: 'ubuntu-latest' permissions: diff --git a/.github/workflows/release-sandbox.yml b/.github/workflows/release-sandbox.yml index f1deb0380c..2c7de7a0f5 100644 --- a/.github/workflows/release-sandbox.yml +++ b/.github/workflows/release-sandbox.yml @@ -16,6 +16,7 @@ on: jobs: build: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'read' diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index caeb0bebe0..29903dfbe8 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -20,6 +20,7 @@ on: jobs: smoke-test: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'ubuntu-latest' permissions: contents: 'write' diff --git a/.github/workflows/trigger_e2e.yml b/.github/workflows/trigger_e2e.yml index babe08e4e3..56da2727c5 100644 --- a/.github/workflows/trigger_e2e.yml +++ b/.github/workflows/trigger_e2e.yml @@ -15,6 +15,7 @@ on: jobs: save_repo_name: + if: "github.repository == 'google-gemini/gemini-cli'" runs-on: 'gemini-cli-ubuntu-16-core' steps: - name: 'Save Repo name' @@ -31,6 +32,7 @@ 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' diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml index edf0995ddd..20a9f51b8a 100644 --- a/.github/workflows/verify-release.yml +++ b/.github/workflows/verify-release.yml @@ -28,6 +28,7 @@ on: jobs: verify-release: + if: "github.repository == 'google-gemini/gemini-cli'" environment: "${{ github.event.inputs.environment || 'prod' }}" strategy: fail-fast: false From ec39aa17c22745921ada8a0b2ae6594d1ca1140a Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Fri, 27 Feb 2026 09:43:18 -0800 Subject: [PATCH 08/29] Moved markdown parsing logic to a separate util file (#20526) --- .../src/ui/utils/InlineMarkdownRenderer.tsx | 211 +---------------- packages/cli/src/ui/utils/TableRenderer.tsx | 2 +- .../src/ui/utils/markdownParsingUtils.test.ts | 2 +- .../cli/src/ui/utils/markdownParsingUtils.ts | 216 ++++++++++++++++++ 4 files changed, 219 insertions(+), 212 deletions(-) create mode 100644 packages/cli/src/ui/utils/markdownParsingUtils.ts diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index 02a34842f4..19d4b3cac8 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -6,223 +6,14 @@ import React from 'react'; import { Text } from 'ink'; -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 { parseMarkdownToANSI } from './markdownParsingUtils.js'; 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 "" -const UNDERLINE_TAG_END_LENGTH = 4; // For "" - 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>|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('') && - fullMatch.endsWith('') && - 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 = ({ text: rawText, defaultColor, diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 143b1fe015..6143571f6a 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -17,7 +17,7 @@ import { widestLineFromStyledChars, } from 'ink'; import { theme } from '../semantic-colors.js'; -import { parseMarkdownToANSI } from './InlineMarkdownRenderer.js'; +import { parseMarkdownToANSI } from './markdownParsingUtils.js'; import { stripUnsafeCharacters } from './textUtils.js'; interface TableRendererProps { diff --git a/packages/cli/src/ui/utils/markdownParsingUtils.test.ts b/packages/cli/src/ui/utils/markdownParsingUtils.test.ts index 05f19f09f7..a9ff96401f 100644 --- a/packages/cli/src/ui/utils/markdownParsingUtils.test.ts +++ b/packages/cli/src/ui/utils/markdownParsingUtils.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, beforeAll, vi } from 'vitest'; import chalk from 'chalk'; -import { parseMarkdownToANSI } from './InlineMarkdownRenderer.js'; +import { parseMarkdownToANSI } from './markdownParsingUtils.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. diff --git a/packages/cli/src/ui/utils/markdownParsingUtils.ts b/packages/cli/src/ui/utils/markdownParsingUtils.ts new file mode 100644 index 0000000000..10f7cb7a40 --- /dev/null +++ b/packages/cli/src/ui/utils/markdownParsingUtils.ts @@ -0,0 +1,216 @@ +/** + * @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 "" +const UNDERLINE_TAG_END_LENGTH = 4; // For "" + +/** + * 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>|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('') && + fullMatch.endsWith('') && + 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; +}; From 23905bcd770a2b76803fe3eee5e904c53560b7c9 Mon Sep 17 00:00:00 2001 From: Adib234 <30782825+Adib234@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:51:47 -0500 Subject: [PATCH 09/29] fix(plan): prevent agent from using ask_user for shell command confirmation (#20504) --- .../core/__snapshots__/prompts.test.ts.snap | 36 +++++++++---------- packages/core/src/prompts/snippets.ts | 2 +- .../tools/__snapshots__/shell.test.ts.snap | 8 +++++ .../coreToolsModelSnapshots.test.ts.snap | 6 +++- .../dynamic-declaration-helpers.ts | 8 +++-- .../definitions/model-family-sets/gemini-3.ts | 2 +- 6 files changed, 39 insertions(+), 23 deletions(-) diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 30603b5443..438251ed1f 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -152,7 +152,7 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -326,7 +326,7 @@ An approved plan is available for this task at \`/tmp/plans/feature-x.md\`. - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -607,7 +607,7 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -758,7 +758,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -897,7 +897,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -1019,7 +1019,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -1656,7 +1656,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -1807,7 +1807,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -1962,7 +1962,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -2117,7 +2117,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -2268,7 +2268,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -2411,7 +2411,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -2561,7 +2561,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -2712,7 +2712,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -3104,7 +3104,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -3255,7 +3255,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -3518,7 +3518,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage @@ -3669,7 +3669,7 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with \`run_shell_command\` that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use \`ask_user\` to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 982a366c3b..0de9b11e25 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -331,7 +331,7 @@ export function renderOperationalGuidelines( - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with ${formatToolName(SHELL_TOOL_NAME)} that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with ${formatToolName(SHELL_TOOL_NAME)} that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). You MUST NOT use ${formatToolName(ASK_USER_TOOL_NAME)} to ask for permission to run a command. - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage diff --git a/packages/core/src/tools/__snapshots__/shell.test.ts.snap b/packages/core/src/tools/__snapshots__/shell.test.ts.snap index 471ce45f6e..b7101cb6b6 100644 --- a/packages/core/src/tools/__snapshots__/shell.test.ts.snap +++ b/packages/core/src/tools/__snapshots__/shell.test.ts.snap @@ -3,6 +3,8 @@ exports[`ShellTool > getDescription > should return the non-windows description when not on windows 1`] = ` "This tool executes a given shell command as \`bash -c \`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`. + The CLI will automatically prompt the user for confirmation before executing any command provided by this tool, so you MUST NOT ask for permission or confirmation separately (e.g., using ask_user). + Efficiency Guidelines: - Quiet Flags: Always prefer silent or quiet flags (e.g., \`npm install --silent\`, \`git --no-pager\`) to reduce output volume while still capturing necessary information. - Pagination: Always disable terminal pagination to ensure commands terminate (e.g., use \`git --no-pager\`, \`systemctl --no-pager\`, or set \`PAGER=cat\`). @@ -20,6 +22,8 @@ exports[`ShellTool > getDescription > should return the non-windows description exports[`ShellTool > getDescription > should return the windows description when on windows 1`] = ` "This tool executes a given shell command as \`powershell.exe -NoProfile -Command \`. Command can start background processes using PowerShell constructs such as \`Start-Process -NoNewWindow\` or \`Start-Job\`. + The CLI will automatically prompt the user for confirmation before executing any command provided by this tool, so you MUST NOT ask for permission or confirmation separately (e.g., using ask_user). + Efficiency Guidelines: - Quiet Flags: Always prefer silent or quiet flags (e.g., \`npm install --silent\`, \`git --no-pager\`) to reduce output volume while still capturing necessary information. - Pagination: Always disable terminal pagination to ensure commands terminate (e.g., use \`git --no-pager\`, \`systemctl --no-pager\`, or set \`PAGER=cat\`). @@ -37,6 +41,8 @@ exports[`ShellTool > getDescription > should return the windows description when exports[`ShellTool > getSchema > should return the base schema when no modelId is provided 1`] = ` "This tool executes a given shell command as \`bash -c \`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`. + The CLI will automatically prompt the user for confirmation before executing any command provided by this tool, so you MUST NOT ask for permission or confirmation separately (e.g., using ask_user). + Efficiency Guidelines: - Quiet Flags: Always prefer silent or quiet flags (e.g., \`npm install --silent\`, \`git --no-pager\`) to reduce output volume while still capturing necessary information. - Pagination: Always disable terminal pagination to ensure commands terminate (e.g., use \`git --no-pager\`, \`systemctl --no-pager\`, or set \`PAGER=cat\`). @@ -54,6 +60,8 @@ exports[`ShellTool > getSchema > should return the base schema when no modelId i exports[`ShellTool > getSchema > should return the schema from the resolver when modelId is provided 1`] = ` "This tool executes a given shell command as \`bash -c \`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`. + The CLI will automatically prompt the user for confirmation before executing any command provided by this tool, so you MUST NOT ask for permission or confirmation separately (e.g., using ask_user). + Efficiency Guidelines: - Quiet Flags: Always prefer silent or quiet flags (e.g., \`npm install --silent\`, \`git --no-pager\`) to reduce output volume while still capturing necessary information. - Pagination: Always disable terminal pagination to ensure commands terminate (e.g., use \`git --no-pager\`, \`systemctl --no-pager\`, or set \`PAGER=cat\`). diff --git a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap index 70cf828d86..4700865d06 100644 --- a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap +++ b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap @@ -568,6 +568,8 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps { "description": "This tool executes a given shell command as \`bash -c \`. To run a command in the background, set the \`is_background\` parameter to true. Do NOT use \`&\` to background commands. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`. + The CLI will automatically prompt the user for confirmation before executing any command provided by this tool, so you MUST NOT ask for permission or confirmation separately (e.g., using ask_user). + Efficiency Guidelines: - Quiet Flags: Always prefer silent or quiet flags (e.g., \`npm install --silent\`, \`git --no-pager\`) to reduce output volume while still capturing necessary information. - Pagination: Always disable terminal pagination to ensure commands terminate (e.g., use \`git --no-pager\`, \`systemctl --no-pager\`, or set \`PAGER=cat\`). @@ -859,7 +861,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. DO NOT use this tool to ask for permission to run shell commands; the run_shell_command tool has built-in confirmation. When using this tool, prefer providing multiple-choice options with detailed descriptions and enable multi-select where appropriate to provide maximum flexibility.", "name": "ask_user", "parametersJsonSchema": { "properties": { @@ -1331,6 +1333,8 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > { "description": "This tool executes a given shell command as \`bash -c \`. To run a command in the background, set the \`is_background\` parameter to true. Do NOT use \`&\` to background commands. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`. + The CLI will automatically prompt the user for confirmation before executing any command provided by this tool, so you MUST NOT ask for permission or confirmation separately (e.g., using ask_user). + Efficiency Guidelines: - Quiet Flags: Always prefer silent or quiet flags (e.g., \`npm install --silent\`, \`git --no-pager\`) to reduce output volume while still capturing necessary information. - Pagination: Always disable terminal pagination to ensure commands terminate (e.g., use \`git --no-pager\`, \`systemctl --no-pager\`, or set \`PAGER=cat\`). diff --git a/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts b/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts index 83ed680ce7..562320e57b 100644 --- a/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts +++ b/packages/core/src/tools/definitions/dynamic-declaration-helpers.ts @@ -45,16 +45,20 @@ export function getShellToolDescription( Background PIDs: Only included if background processes were started. Process Group PGID: Only included if available.`; + const confirmationNote = ` + + The CLI will automatically prompt the user for confirmation before executing any command provided by this tool, so you MUST NOT ask for permission or confirmation separately (e.g., using ask_user).`; + if (os.platform() === 'win32') { const backgroundInstructions = enableInteractiveShell ? 'To run a command in the background, set the `is_background` parameter to true. Do NOT use PowerShell background constructs.' : 'Command can start background processes using PowerShell constructs such as `Start-Process -NoNewWindow` or `Start-Job`.'; - return `This tool executes a given shell command as \`powershell.exe -NoProfile -Command \`. ${backgroundInstructions}${efficiencyGuidelines}${returnedInfo}`; + return `This tool executes a given shell command as \`powershell.exe -NoProfile -Command \`. ${backgroundInstructions}${confirmationNote}${efficiencyGuidelines}${returnedInfo}`; } else { const backgroundInstructions = enableInteractiveShell ? 'To run a command in the background, set the `is_background` parameter to true. Do NOT use `&` to background commands.' : 'Command can start background processes using `&`.'; - return `This tool executes a given shell command as \`bash -c \`. ${backgroundInstructions} Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${efficiencyGuidelines}${returnedInfo}`; + return `This tool executes a given shell command as \`bash -c \`. ${backgroundInstructions} Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${confirmationNote}${efficiencyGuidelines}${returnedInfo}`; } } diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index 7c4fddc9f6..7c1f171366 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -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. DO NOT use this tool to ask for permission to run shell commands; the run_shell_command tool has built-in confirmation. When using this tool, prefer providing multiple-choice options with detailed descriptions and enable multi-select where appropriate to provide maximum flexibility.', parametersJsonSchema: { type: 'object', required: ['questions'], From fdd844b405941dcbed4f28cfc0eade51657c22c4 Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Fri, 27 Feb 2026 13:04:43 -0500 Subject: [PATCH 10/29] fix(core): disable retries for code assist streaming requests (#20561) --- packages/core/src/code_assist/server.test.ts | 11 ++--------- packages/core/src/code_assist/server.ts | 14 +++++--------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/packages/core/src/code_assist/server.test.ts b/packages/core/src/code_assist/server.test.ts index aa5cbae7fe..63566c4662 100644 --- a/packages/core/src/code_assist/server.test.ts +++ b/packages/core/src/code_assist/server.test.ts @@ -84,6 +84,7 @@ describe('CodeAssistServer', () => { body: expect.any(String), signal: undefined, retryConfig: { + retryDelay: 1000, retry: 3, noResponseRetries: 3, statusCodesToRetry: [ @@ -410,15 +411,7 @@ describe('CodeAssistServer', () => { 'Content-Type': 'application/json', }, signal: undefined, - retryConfig: { - retry: 3, - noResponseRetries: 3, - statusCodesToRetry: [ - [429, 429], - [499, 499], - [500, 599], - ], - }, + retry: false, }); expect(results).toHaveLength(2); diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts index dfcc693ca7..2c726b3c1e 100644 --- a/packages/core/src/code_assist/server.ts +++ b/packages/core/src/code_assist/server.ts @@ -62,6 +62,7 @@ export interface HttpOptions { export const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'; export const CODE_ASSIST_API_VERSION = 'v1internal'; +const GENERATE_CONTENT_RETRY_DELAY_IN_MILLISECONDS = 1000; export class CodeAssistServer implements ContentGenerator { constructor( @@ -141,6 +142,7 @@ export class CodeAssistServer implements ContentGenerator { this.sessionId, ), req.config?.abortSignal, + GENERATE_CONTENT_RETRY_DELAY_IN_MILLISECONDS, ); const duration = formatProtoJsonDuration(Date.now() - start); const streamingLatency: StreamingLatency = { @@ -294,6 +296,7 @@ export class CodeAssistServer implements ContentGenerator { method: string, req: object, signal?: AbortSignal, + retryDelay: number = 100, ): Promise { const res = await this.client.request({ url: this.getMethodUrl(method), @@ -306,6 +309,7 @@ export class CodeAssistServer implements ContentGenerator { body: JSON.stringify(req), signal, retryConfig: { + retryDelay, retry: 3, noResponseRetries: 3, statusCodesToRetry: [ @@ -361,15 +365,7 @@ export class CodeAssistServer implements ContentGenerator { responseType: 'stream', body: JSON.stringify(req), signal, - retryConfig: { - retry: 3, - noResponseRetries: 3, - statusCodesToRetry: [ - [429, 429], - [499, 499], - [500, 599], - ], - }, + retry: false, }); return (async function* (): AsyncGenerator { From b2d6844f9b844a8e6281cd2a8afd9bf45170cfeb Mon Sep 17 00:00:00 2001 From: Gaurav <39389231+gsquared94@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:15:06 -0800 Subject: [PATCH 11/29] feat(billing): implement G1 AI credits overage flow with billing telemetry (#18590) --- docs/cli/settings.md | 6 + docs/reference/configuration.md | 9 + packages/cli/src/config/settingsSchema.ts | 30 ++ packages/cli/src/test-utils/render.tsx | 2 + packages/cli/src/ui/AppContainer.tsx | 34 ++ .../cli/src/ui/commands/statsCommand.test.ts | 16 +- packages/cli/src/ui/commands/statsCommand.ts | 18 +- .../src/ui/components/DialogManager.test.tsx | 4 + .../cli/src/ui/components/DialogManager.tsx | 24 ++ .../ui/components/EmptyWalletDialog.test.tsx | 218 +++++++++++++ .../src/ui/components/EmptyWalletDialog.tsx | 110 +++++++ .../cli/src/ui/components/Footer.test.tsx | 6 + .../src/ui/components/HistoryItemDisplay.tsx | 1 + .../ui/components/OverageMenuDialog.test.tsx | 228 +++++++++++++ .../src/ui/components/OverageMenuDialog.tsx | 113 +++++++ .../cli/src/ui/components/StatsDisplay.tsx | 13 + .../EmptyWalletDialog.test.tsx.snap | 49 +++ .../OverageMenuDialog.test.tsx.snap | 47 +++ .../cli/src/ui/contexts/UIActionsContext.tsx | 3 + .../cli/src/ui/contexts/UIStateContext.tsx | 31 ++ .../src/ui/hooks/creditsFlowHandler.test.ts | 240 ++++++++++++++ .../cli/src/ui/hooks/creditsFlowHandler.ts | 290 +++++++++++++++++ .../src/ui/hooks/useQuotaAndFallback.test.ts | 307 +++++++++++++++++- .../cli/src/ui/hooks/useQuotaAndFallback.ts | 109 ++++++- packages/cli/src/ui/types.ts | 1 + packages/core/src/billing/billing.test.ts | 254 +++++++++++++++ packages/core/src/billing/billing.ts | 186 +++++++++++ packages/core/src/billing/index.ts | 7 + .../core/src/code_assist/codeAssist.test.ts | 4 + packages/core/src/code_assist/codeAssist.ts | 2 + packages/core/src/code_assist/converter.ts | 19 ++ packages/core/src/code_assist/server.ts | 102 +++++- packages/core/src/code_assist/setup.ts | 3 + packages/core/src/code_assist/types.ts | 32 ++ packages/core/src/config/config.ts | 43 +++ packages/core/src/core/contentGenerator.ts | 4 +- .../core/src/core/fakeContentGenerator.ts | 3 +- .../core/src/core/loggingContentGenerator.ts | 6 +- packages/core/src/fallback/handler.ts | 3 + packages/core/src/fallback/types.ts | 1 + packages/core/src/index.ts | 5 + .../core/src/telemetry/billingEvents.test.ts | 206 ++++++++++++ packages/core/src/telemetry/billingEvents.ts | 255 +++++++++++++++ .../core/src/telemetry/conseca-logger.test.ts | 1 + packages/core/src/telemetry/index.ts | 4 + packages/core/src/telemetry/loggers.test.ts | 24 +- packages/core/src/telemetry/loggers.ts | 15 + packages/core/src/telemetry/metrics.ts | 52 +++ packages/core/src/telemetry/sanitize.test.ts | 1 + packages/core/src/telemetry/sdk.test.ts | 1 + .../core/src/telemetry/telemetryAttributes.ts | 2 + packages/core/src/utils/googleErrors.ts | 4 +- .../core/src/utils/googleQuotaErrors.test.ts | 20 ++ packages/core/src/utils/googleQuotaErrors.ts | 19 ++ schemas/settings.schema.json | 18 + 55 files changed, 3182 insertions(+), 23 deletions(-) create mode 100644 packages/cli/src/ui/components/EmptyWalletDialog.test.tsx create mode 100644 packages/cli/src/ui/components/EmptyWalletDialog.tsx create mode 100644 packages/cli/src/ui/components/OverageMenuDialog.test.tsx create mode 100644 packages/cli/src/ui/components/OverageMenuDialog.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/EmptyWalletDialog.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/OverageMenuDialog.test.tsx.snap create mode 100644 packages/cli/src/ui/hooks/creditsFlowHandler.test.ts create mode 100644 packages/cli/src/ui/hooks/creditsFlowHandler.ts create mode 100644 packages/core/src/billing/billing.test.ts create mode 100644 packages/core/src/billing/billing.ts create mode 100644 packages/core/src/billing/index.ts create mode 100644 packages/core/src/telemetry/billingEvents.test.ts create mode 100644 packages/core/src/telemetry/billingEvents.ts diff --git a/docs/cli/settings.md b/docs/cli/settings.md index b0c12116d6..ea5ea1ef93 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -80,6 +80,12 @@ they appear in the UI. | -------- | ------------- | ---------------------------- | ------- | | IDE Mode | `ide.enabled` | Enable IDE integration mode. | `false` | +### Billing + +| UI Label | Setting | Description | Default | +| ---------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Overage Strategy | `billing.overageStrategy` | How to handle quota exhaustion when AI credits are available. 'ask' prompts each time, 'always' automatically uses credits, 'never' disables credit usage. | `"ask"` | + ### Model | UI Label | Setting | Description | Default | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index c1c67803b0..5e7e7abacb 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -357,6 +357,15 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +#### `billing` + +- **`billing.overageStrategy`** (enum): + - **Description:** How to handle quota exhaustion when AI credits are + available. 'ask' prompts each time, 'always' automatically uses credits, + 'never' disables credit usage. + - **Default:** `"ask"` + - **Values:** `"ask"`, `"always"`, `"never"` + #### `model` - **`model.name`** (string): diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 48a7641766..ca538c6a5a 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -828,6 +828,36 @@ const SETTINGS_SCHEMA = { ref: 'TelemetrySettings', }, + billing: { + type: 'object', + label: 'Billing', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: 'Billing and AI credits settings.', + showInDialog: false, + properties: { + overageStrategy: { + type: 'enum', + label: 'Overage Strategy', + category: 'Advanced', + requiresRestart: false, + default: 'ask', + description: oneLine` + How to handle quota exhaustion when AI credits are available. + 'ask' prompts each time, 'always' automatically uses credits, + 'never' disables credit usage. + `, + showInDialog: true, + options: [ + { value: 'ask', label: 'Ask each time' }, + { value: 'always', label: 'Always use credits' }, + { value: 'never', label: 'Never use credits' }, + ], + }, + }, + }, + model: { type: 'object', label: 'Model', diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 2cfb89d0f2..921bd3d7dd 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -591,6 +591,8 @@ const mockUIActions: UIActions = { handleClearScreen: vi.fn(), handleProQuotaChoice: vi.fn(), handleValidationChoice: vi.fn(), + handleOverageMenuChoice: vi.fn(), + handleEmptyWalletChoice: vi.fn(), setQueueErrorMessage: vi.fn(), popAllMessages: vi.fn(), handleApiKeySubmit: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 986bcafaa1..1ddee45b0d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -47,6 +47,7 @@ import { type IdeInfo, type IdeContext, type UserTierId, + type GeminiUserTier, type UserFeedbackPayload, type AgentDefinition, type ApprovalMode, @@ -82,6 +83,8 @@ import { CoreToolCallStatus, generateSteeringAckMessage, buildUserSteeringHintPrompt, + logBillingEvent, + ApiKeyUpdatedEvent, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; @@ -391,6 +394,9 @@ export const AppContainer = (props: AppContainerProps) => { ? { remaining, limit, resetTime } : undefined; }); + const [paidTier, setPaidTier] = useState( + undefined, + ); const [isConfigInitialized, setConfigInitialized] = useState(false); @@ -686,10 +692,17 @@ export const AppContainer = (props: AppContainerProps) => { handleProQuotaChoice, validationRequest, handleValidationChoice, + // G1 AI Credits + overageMenuRequest, + handleOverageMenuChoice, + emptyWalletRequest, + handleEmptyWalletChoice, } = useQuotaAndFallback({ config, historyManager, userTier, + paidTier, + settings, setModelSwitchedFromQuotaError, onShowAuthSelection: () => setAuthState(AuthState.Updating), }); @@ -729,6 +742,8 @@ export const AppContainer = (props: AppContainerProps) => { const handleAuthSelect = useCallback( async (authType: AuthType | undefined, scope: LoadableSettingScope) => { if (authType) { + const previousAuthType = + config.getContentGeneratorConfig()?.authType ?? 'unknown'; if (authType === AuthType.LOGIN_WITH_GOOGLE) { setAuthContext({ requiresRestart: true }); } else { @@ -741,6 +756,10 @@ export const AppContainer = (props: AppContainerProps) => { config.setRemoteAdminSettings(undefined); await config.refreshAuth(authType); setAuthState(AuthState.Authenticated); + logBillingEvent( + config, + new ApiKeyUpdatedEvent(previousAuthType, authType), + ); } catch (e) { if (e instanceof ChangeAuthRequestedError) { return; @@ -803,6 +822,7 @@ Logging in with Google... Restarting Gemini CLI to continue. // Only sync when not currently authenticating if (authState === AuthState.Authenticated) { setUserTier(config.getUserTier()); + setPaidTier(config.getUserPaidTier()); } }, [config, authState]); @@ -2006,6 +2026,8 @@ Logging in with Google... Restarting Gemini CLI to continue. showIdeRestartPrompt || !!proQuotaRequest || !!validationRequest || + !!overageMenuRequest || + !!emptyWalletRequest || isSessionBrowserOpen || authState === AuthState.AwaitingApiKeyInput || !!newAgents; @@ -2033,6 +2055,8 @@ Logging in with Google... Restarting Gemini CLI to continue. hasLoopDetectionConfirmationRequest || !!proQuotaRequest || !!validationRequest || + !!overageMenuRequest || + !!emptyWalletRequest || !!customDialog; const allowPlanMode = @@ -2243,6 +2267,9 @@ Logging in with Google... Restarting Gemini CLI to continue. stats: quotaStats, proQuotaRequest, validationRequest, + // G1 AI Credits dialog state + overageMenuRequest, + emptyWalletRequest, }, contextFileNames, errorCount, @@ -2367,6 +2394,8 @@ Logging in with Google... Restarting Gemini CLI to continue. quotaStats, proQuotaRequest, validationRequest, + overageMenuRequest, + emptyWalletRequest, contextFileNames, errorCount, availableTerminalHeight, @@ -2448,6 +2477,9 @@ Logging in with Google... Restarting Gemini CLI to continue. handleClearScreen, handleProQuotaChoice, handleValidationChoice, + // G1 AI Credits handlers + handleOverageMenuChoice, + handleEmptyWalletChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, @@ -2534,6 +2566,8 @@ Logging in with Google... Restarting Gemini CLI to continue. handleClearScreen, handleProQuotaChoice, handleValidationChoice, + handleOverageMenuChoice, + handleEmptyWalletChoice, openSessionBrowser, closeSessionBrowser, handleResumeSession, diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index 63fe3eb9e5..2f36c333b9 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -39,11 +39,18 @@ describe('statsCommand', () => { mockContext.session.stats.sessionStartTime = startTime; }); - it('should display general session stats when run with no subcommand', () => { + it('should display general session stats when run with no subcommand', async () => { if (!statsCommand.action) throw new Error('Command has no action'); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - statsCommand.action(mockContext, ''); + mockContext.services.config = { + refreshUserQuota: vi.fn(), + refreshAvailableCredits: vi.fn(), + getUserTierName: vi.fn(), + getUserPaidTier: vi.fn(), + getModel: vi.fn(), + } as unknown as Config; + + await statsCommand.action(mockContext, ''); const expectedDuration = formatDuration( endTime.getTime() - startTime.getTime(), @@ -55,6 +62,7 @@ describe('statsCommand', () => { tier: undefined, userEmail: 'mock@example.com', currentModel: undefined, + creditBalance: undefined, }); }); @@ -78,6 +86,8 @@ describe('statsCommand', () => { getQuotaRemaining: mockGetQuotaRemaining, getQuotaLimit: mockGetQuotaLimit, getQuotaResetTime: mockGetQuotaResetTime, + getUserPaidTier: vi.fn(), + refreshAvailableCredits: vi.fn(), } as unknown as Config; await statsCommand.action(mockContext, ''); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index b90e7309e1..1ded006618 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -11,7 +11,10 @@ import type { } from '../types.js'; import { MessageType } from '../types.js'; import { formatDuration } from '../utils/formatters.js'; -import { UserAccountManager } from '@google/gemini-cli-core'; +import { + UserAccountManager, + getG1CreditBalance, +} from '@google/gemini-cli-core'; import { type CommandContext, type SlashCommand, @@ -27,8 +30,10 @@ function getUserIdentity(context: CommandContext) { const userEmail = cachedAccount ?? undefined; const tier = context.services.config?.getUserTierName(); + const paidTier = context.services.config?.getUserPaidTier(); + const creditBalance = getG1CreditBalance(paidTier) ?? undefined; - return { selectedAuthType, userEmail, tier }; + return { selectedAuthType, userEmail, tier, creditBalance }; } async function defaultSessionView(context: CommandContext) { @@ -43,7 +48,8 @@ async function defaultSessionView(context: CommandContext) { } const wallDuration = now.getTime() - sessionStartTime.getTime(); - const { selectedAuthType, userEmail, tier } = getUserIdentity(context); + const { selectedAuthType, userEmail, tier, creditBalance } = + getUserIdentity(context); const currentModel = context.services.config?.getModel(); const statsItem: HistoryItemStats = { @@ -53,10 +59,14 @@ async function defaultSessionView(context: CommandContext) { userEmail, tier, currentModel, + creditBalance, }; if (context.services.config) { - const quota = await context.services.config.refreshUserQuota(); + const [quota] = await Promise.all([ + context.services.config.refreshUserQuota(), + context.services.config.refreshAvailableCredits(), + ]); if (quota) { statsItem.quotas = quota; statsItem.pooledRemaining = context.services.config.getQuotaRemaining(); diff --git a/packages/cli/src/ui/components/DialogManager.test.tsx b/packages/cli/src/ui/components/DialogManager.test.tsx index 2dbdd5019b..6329ca89a1 100644 --- a/packages/cli/src/ui/components/DialogManager.test.tsx +++ b/packages/cli/src/ui/components/DialogManager.test.tsx @@ -80,6 +80,8 @@ describe('DialogManager', () => { stats: undefined, proQuotaRequest: null, validationRequest: null, + overageMenuRequest: null, + emptyWalletRequest: null, }, shouldShowIdePrompt: false, isFolderTrustDialogOpen: false, @@ -132,6 +134,8 @@ describe('DialogManager', () => { resolve: vi.fn(), }, validationRequest: null, + overageMenuRequest: null, + emptyWalletRequest: null, }, }, 'ProQuotaDialog', diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index c90194052a..32edbc9d3f 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -18,6 +18,8 @@ import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { PrivacyNotice } from '../privacy/PrivacyNotice.js'; import { ProQuotaDialog } from './ProQuotaDialog.js'; import { ValidationDialog } from './ValidationDialog.js'; +import { OverageMenuDialog } from './OverageMenuDialog.js'; +import { EmptyWalletDialog } from './EmptyWalletDialog.js'; import { runExitCleanup } from '../../utils/cleanup.js'; import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; import { SessionBrowser } from './SessionBrowser.js'; @@ -152,6 +154,28 @@ export const DialogManager = ({ /> ); } + if (uiState.quota.overageMenuRequest) { + return ( + + ); + } + if (uiState.quota.emptyWalletRequest) { + return ( + + ); + } if (uiState.shouldShowIdePrompt) { return ( void }, key: string) => { + act(() => { + stdin.write(key); + }); +}; + +describe('EmptyWalletDialog', () => { + const mockOnChoice = vi.fn(); + const mockOnGetCredits = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('rendering', () => { + it('should match snapshot with fallback available', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('should match snapshot without fallback', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('should display the model name and usage limit message', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame() ?? ''; + expect(output).toContain('gemini-2.5-pro'); + expect(output).toContain('Usage limit reached'); + unmount(); + }); + + it('should display purchase prompt and credits update notice', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame() ?? ''; + expect(output).toContain('purchase more AI Credits'); + expect(output).toContain( + 'Newly purchased AI credits may take a few minutes to update', + ); + unmount(); + }); + + it('should display reset time when provided', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame() ?? ''; + expect(output).toContain('3:45 PM'); + expect(output).toContain('Access resets at'); + unmount(); + }); + + it('should not display reset time when not provided', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame() ?? ''; + expect(output).not.toContain('Access resets at'); + unmount(); + }); + + it('should display slash command hints', async () => { + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame() ?? ''; + expect(output).toContain('/stats'); + expect(output).toContain('/model'); + expect(output).toContain('/auth'); + unmount(); + }); + }); + + describe('onChoice handling', () => { + it('should call onGetCredits and onChoice when get_credits is selected', async () => { + // get_credits is the first item, so just press Enter + const { unmount, stdin, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnGetCredits).toHaveBeenCalled(); + expect(mockOnChoice).toHaveBeenCalledWith('get_credits'); + }); + unmount(); + }); + + it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => { + const { unmount, stdin, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnChoice).toHaveBeenCalledWith('get_credits'); + }); + unmount(); + }); + + it('should call onChoice with use_fallback when selected', async () => { + // With fallback: items are [get_credits, use_fallback, stop] + // use_fallback is the second item: Down + Enter + const { unmount, stdin, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + writeKey(stdin, '\x1b[B'); // Down arrow + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnChoice).toHaveBeenCalledWith('use_fallback'); + }); + unmount(); + }); + + it('should call onChoice with stop when selected', async () => { + // Without fallback: items are [get_credits, stop] + // stop is the second item: Down + Enter + const { unmount, stdin, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); + + writeKey(stdin, '\x1b[B'); // Down arrow + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(mockOnChoice).toHaveBeenCalledWith('stop'); + }); + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/EmptyWalletDialog.tsx b/packages/cli/src/ui/components/EmptyWalletDialog.tsx new file mode 100644 index 0000000000..25d85829d3 --- /dev/null +++ b/packages/cli/src/ui/components/EmptyWalletDialog.tsx @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { theme } from '../semantic-colors.js'; + +/** Available choices in the empty wallet dialog */ +export type EmptyWalletChoice = 'get_credits' | 'use_fallback' | 'stop'; + +interface EmptyWalletDialogProps { + /** The model that hit the quota limit */ + failedModel: string; + /** The fallback model to offer (omit if none available) */ + fallbackModel?: string; + /** Time when access resets (human-readable) */ + resetTime?: string; + /** Callback to log click and open the browser for purchasing credits */ + onGetCredits?: () => void; + /** Callback when user makes a selection */ + onChoice: (choice: EmptyWalletChoice) => void; +} + +export function EmptyWalletDialog({ + failedModel, + fallbackModel, + resetTime, + onGetCredits, + onChoice, +}: EmptyWalletDialogProps): React.JSX.Element { + const items: Array<{ + label: string; + value: EmptyWalletChoice; + key: string; + }> = [ + { + label: 'Get AI Credits - Open browser to purchase credits', + value: 'get_credits', + key: 'get_credits', + }, + ]; + + if (fallbackModel) { + items.push({ + label: `Switch to ${fallbackModel}`, + value: 'use_fallback', + key: 'use_fallback', + }); + } + + items.push({ + label: 'Stop - Abort request', + value: 'stop', + key: 'stop', + }); + + const handleSelect = (choice: EmptyWalletChoice) => { + if (choice === 'get_credits') { + onGetCredits?.(); + } + onChoice(choice); + }; + + return ( + + + + Usage limit reached for {failedModel}. + + {resetTime && Access resets at {resetTime}.} + + + /stats + {' '} + model for usage details + + + + /model + {' '} + to switch models. + + + + /auth + {' '} + to switch to API key. + + + + To continue using this model now, purchase more AI Credits. + + + + Newly purchased AI credits may take a few minutes to update. + + + + How would you like to proceed? + + + + + + ); +} diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 143e8319a3..2d8662cd5d 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -177,6 +177,8 @@ describe('