From db00c5abf388e07202af227ac77b31e07238b379 Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Thu, 12 Feb 2026 14:25:24 -0500 Subject: [PATCH] feat(cli): prototype clean UI toggle and minimal-mode bleed-through (#18683) --- docs/cli/commands.md | 2 + docs/cli/keyboard-shortcuts.md | 9 +- packages/cli/src/config/keyBindings.ts | 4 +- packages/cli/src/test-utils/render.tsx | 5 + packages/cli/src/ui/App.test.tsx | 1 + packages/cli/src/ui/AppContainer.test.tsx | 34 ++- packages/cli/src/ui/AppContainer.tsx | 92 +++++- packages/cli/src/ui/components/AppHeader.tsx | 11 +- .../cli/src/ui/components/Composer.test.tsx | 227 +++++++++++++- packages/cli/src/ui/components/Composer.tsx | 286 +++++++++++++----- .../src/ui/components/InputPrompt.test.tsx | 106 ++++++- .../cli/src/ui/components/InputPrompt.tsx | 37 ++- .../src/ui/components/MainContent.test.tsx | 94 +++++- .../cli/src/ui/components/MainContent.tsx | 18 +- .../src/ui/components/ShortcutsHelp.test.tsx | 6 + .../cli/src/ui/components/ShortcutsHelp.tsx | 29 +- .../cli/src/ui/components/ShortcutsHint.tsx | 7 +- .../__snapshots__/MainContent.test.tsx.snap | 19 +- .../__snapshots__/ShortcutsHelp.test.tsx.snap | 28 +- .../cli/src/ui/contexts/UIActionsContext.tsx | 4 + .../cli/src/ui/contexts/UIStateContext.tsx | 1 + packages/cli/src/ui/keyMatchers.test.ts | 2 +- packages/cli/src/utils/persistentState.ts | 1 + 23 files changed, 872 insertions(+), 151 deletions(-) diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 6e563cda11..c5e6b6747f 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -120,6 +120,8 @@ Slash commands provide meta-level control over the CLI itself. - **`/shortcuts`** - **Description:** Toggle the shortcuts panel above the input. - **Shortcut:** Press `?` when the prompt is empty. + - **Note:** This is separate from the clean UI detail toggle on double-`Tab`, + which switches between minimal and full UI chrome. - **`/hooks`** - **Description:** Manage hooks, which allow you to intercept and customize diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 0dc32b7779..ffc0a39fda 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -114,8 +114,8 @@ available combinations. | Dismiss background shell list. | `Esc` | | Move focus from background shell to Gemini. | `Shift + Tab` | | Move focus from background shell list to Gemini. | `Tab (no Shift)` | -| Show warning when trying to unfocus background shell via Tab. | `Tab (no Shift)` | -| Show warning when trying to unfocus shell input via Tab. | `Tab (no Shift)` | +| Show warning when trying to move focus away from background shell. | `Tab (no Shift)` | +| Show warning when trying to move focus away from shell input. | `Tab (no Shift)` | | Move focus from Gemini to the active shell. | `Tab (no Shift)` | | Move focus from the shell back to Gemini. | `Shift + Tab` | | Clear the terminal screen and redraw the UI. | `Ctrl + L` | @@ -134,6 +134,11 @@ available combinations. The panel also auto-hides while the agent is running/streaming or when action-required dialogs are shown. Press `?` again to close the panel and insert a `?` into the prompt. +- `Tab` + `Tab` (while typing in the prompt): Toggle between minimal and full UI + details when no completion/search interaction is active. The selected mode is + remembered for future sessions. Full UI remains the default on first run, and + single `Tab` keeps its existing completion/focus behavior. +- `Shift + Tab` (while typing in the prompt): Cycle approval modes. - `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line mode. - `Esc` pressed twice quickly: Clear the input prompt if it is not empty, diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index adf88d4d25..c3f1f70fbe 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -516,9 +516,9 @@ export const commandDescriptions: Readonly> = { [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Move focus from background shell list to Gemini.', [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: - 'Show warning when trying to unfocus background shell via Tab.', + 'Show warning when trying to move focus away from background shell.', [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: - 'Show warning when trying to unfocus shell input via Tab.', + 'Show warning when trying to move focus away from shell input.', [Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.', [Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.', [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 0c8eac325e..10ad4281ef 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -150,6 +150,7 @@ const baseMockUiState = { terminalWidth: 120, terminalHeight: 40, currentModel: 'gemini-pro', + cleanUiDetailsVisible: false, terminalBackgroundColor: undefined, activePtyId: undefined, backgroundShells: new Map(), @@ -204,6 +205,10 @@ const mockUIActions: UIActions = { handleApiKeyCancel: vi.fn(), setBannerVisible: vi.fn(), setShortcutsHelpVisible: vi.fn(), + setCleanUiDetailsVisible: vi.fn(), + toggleCleanUiDetailsVisible: vi.fn(), + revealCleanUiDetailsTemporarily: vi.fn(), + handleWarning: vi.fn(), setEmbeddedShellFocused: vi.fn(), dismissBackgroundShell: vi.fn(), setActiveBackgroundShellPid: vi.fn(), diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 7d817f44f5..475a04e18e 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -66,6 +66,7 @@ describe('App', () => { const mockUIState: Partial = { streamingState: StreamingState.Idle, + cleanUiDetailsVisible: true, quittingMessages: null, dialogsVisible: false, mainControlsRef: { diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index ff84834c69..028584537d 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -14,7 +14,7 @@ import { type Mock, type MockedObject, } from 'vitest'; -import { render } from '../test-utils/render.js'; +import { render, persistentStateMock } from '../test-utils/render.js'; import { waitFor } from '../test-utils/async.js'; import { cleanup } from 'ink-testing-library'; import { act, useContext, type ReactElement } from 'react'; @@ -299,6 +299,7 @@ describe('AppContainer State Management', () => { }; beforeEach(() => { + persistentStateMock.reset(); vi.clearAllMocks(); mockIdeClient.getInstance.mockReturnValue(new Promise(() => {})); @@ -488,6 +489,37 @@ describe('AppContainer State Management', () => { await waitFor(() => expect(capturedUIState).toBeTruthy()); unmount!(); }); + + it('shows full UI details by default', async () => { + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + + await waitFor(() => { + expect(capturedUIState.cleanUiDetailsVisible).toBe(true); + }); + unmount!(); + }); + + it('starts in minimal UI mode when Focus UI preference is persisted', async () => { + persistentStateMock.get.mockReturnValueOnce(true); + + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ + settings: mockSettings, + }); + unmount = result.unmount; + }); + + await waitFor(() => { + expect(capturedUIState.cleanUiDetailsVisible).toBe(false); + }); + expect(persistentStateMock.get).toHaveBeenCalledWith('focusUiEnabled'); + unmount!(); + }); }); describe('State Initialization', () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index a2f25a71de..4c590c21eb 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -49,6 +49,7 @@ import { type UserTierId, type UserFeedbackPayload, type AgentDefinition, + type ApprovalMode, IdeClient, ideContextStore, getErrorMessage, @@ -133,6 +134,7 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js'; import { type ExtensionManager } from '../config/extension-manager.js'; import { requestConsentInteractive } from '../config/extensions/consent.js'; import { useSessionBrowser } from './hooks/useSessionBrowser.js'; +import { persistentState } from '../utils/persistentState.js'; import { useSessionResume } from './hooks/useSessionResume.js'; import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js'; import { isWorkspaceTrusted } from '../config/trustedFolders.js'; @@ -184,6 +186,9 @@ interface AppContainerProps { resumedSessionData?: ResumedSessionData; } +const APPROVAL_MODE_REVEAL_DURATION_MS = 1200; +const FOCUS_UI_ENABLED_STATE_KEY = 'focusUiEnabled'; + /** * The fraction of the terminal width to allocate to the shell. * This provides horizontal padding. @@ -796,7 +801,65 @@ Logging in with Google... Restarting Gemini CLI to continue. const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>( () => {}, ); + const [focusUiEnabledByDefault] = useState( + () => persistentState.get(FOCUS_UI_ENABLED_STATE_KEY) === true, + ); const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false); + const [cleanUiDetailsVisible, setCleanUiDetailsVisibleState] = useState( + !focusUiEnabledByDefault, + ); + const modeRevealTimeoutRef = useRef(null); + const cleanUiDetailsPinnedRef = useRef(!focusUiEnabledByDefault); + + const clearModeRevealTimeout = useCallback(() => { + if (modeRevealTimeoutRef.current) { + clearTimeout(modeRevealTimeoutRef.current); + modeRevealTimeoutRef.current = null; + } + }, []); + + const persistFocusUiPreference = useCallback((isFullUiVisible: boolean) => { + persistentState.set(FOCUS_UI_ENABLED_STATE_KEY, !isFullUiVisible); + }, []); + + const setCleanUiDetailsVisible = useCallback( + (visible: boolean) => { + clearModeRevealTimeout(); + cleanUiDetailsPinnedRef.current = visible; + setCleanUiDetailsVisibleState(visible); + persistFocusUiPreference(visible); + }, + [clearModeRevealTimeout, persistFocusUiPreference], + ); + + const toggleCleanUiDetailsVisible = useCallback(() => { + clearModeRevealTimeout(); + setCleanUiDetailsVisibleState((visible) => { + const nextVisible = !visible; + cleanUiDetailsPinnedRef.current = nextVisible; + persistFocusUiPreference(nextVisible); + return nextVisible; + }); + }, [clearModeRevealTimeout, persistFocusUiPreference]); + + const revealCleanUiDetailsTemporarily = useCallback( + (durationMs: number = APPROVAL_MODE_REVEAL_DURATION_MS) => { + if (cleanUiDetailsPinnedRef.current) { + return; + } + clearModeRevealTimeout(); + setCleanUiDetailsVisibleState(true); + modeRevealTimeoutRef.current = setTimeout(() => { + if (!cleanUiDetailsPinnedRef.current) { + setCleanUiDetailsVisibleState(false); + } + modeRevealTimeoutRef.current = null; + }, durationMs); + }, + [clearModeRevealTimeout], + ); + + useEffect(() => () => clearModeRevealTimeout(), [clearModeRevealTimeout]); const slashCommandActions = useMemo( () => ({ @@ -1057,11 +1120,25 @@ Logging in with Google... Restarting Gemini CLI to continue. const shouldShowActionRequiredTitle = inactivityStatus === 'action_required'; const shouldShowSilentWorkingTitle = inactivityStatus === 'silent_working'; + const handleApprovalModeChangeWithUiReveal = useCallback( + (mode: ApprovalMode) => { + void handleApprovalModeChange(mode); + if (!cleanUiDetailsVisible) { + revealCleanUiDetailsTemporarily(APPROVAL_MODE_REVEAL_DURATION_MS); + } + }, + [ + handleApprovalModeChange, + cleanUiDetailsVisible, + revealCleanUiDetailsTemporarily, + ], + ); + // Auto-accept indicator const showApprovalModeIndicator = useApprovalModeIndicator({ config, addItem: historyManager.addItem, - onApprovalModeChange: handleApprovalModeChange, + onApprovalModeChange: handleApprovalModeChangeWithUiReveal, isActive: !embeddedShellFocused, }); @@ -1377,6 +1454,9 @@ Logging in with Google... Restarting Gemini CLI to continue. if (tabFocusTimeoutRef.current) { clearTimeout(tabFocusTimeoutRef.current); } + if (modeRevealTimeoutRef.current) { + clearTimeout(modeRevealTimeoutRef.current); + } }; }, [showTransientMessage]); @@ -1977,6 +2057,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ctrlDPressedOnce: ctrlDPressCount >= 1, showEscapePrompt, shortcutsHelpVisible, + cleanUiDetailsVisible, isFocused, elapsedTime, currentLoadingPhrase, @@ -2087,6 +2168,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ctrlDPressCount, showEscapePrompt, shortcutsHelpVisible, + cleanUiDetailsVisible, isFocused, elapsedTime, currentLoadingPhrase, @@ -2188,6 +2270,10 @@ Logging in with Google... Restarting Gemini CLI to continue. handleApiKeyCancel, setBannerVisible, setShortcutsHelpVisible, + setCleanUiDetailsVisible, + toggleCleanUiDetailsVisible, + revealCleanUiDetailsTemporarily, + handleWarning, setEmbeddedShellFocused, dismissBackgroundShell, setActiveBackgroundShellPid, @@ -2264,6 +2350,10 @@ Logging in with Google... Restarting Gemini CLI to continue. handleApiKeyCancel, setBannerVisible, setShortcutsHelpVisible, + setCleanUiDetailsVisible, + toggleCleanUiDetailsVisible, + revealCleanUiDetailsTemporarily, + handleWarning, setEmbeddedShellFocused, dismissBackgroundShell, setActiveBackgroundShellPid, diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index 38b0f9b468..ad5e2f67d2 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -17,9 +17,10 @@ import { useTips } from '../hooks/useTips.js'; interface AppHeaderProps { version: string; + showDetails?: boolean; } -export const AppHeader = ({ version }: AppHeaderProps) => { +export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => { const settings = useSettings(); const config = useConfig(); const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState(); @@ -27,6 +28,14 @@ export const AppHeader = ({ version }: AppHeaderProps) => { const { bannerText } = useBanner(bannerData); const { showTips } = useTips(); + if (!showDetails) { + return ( + +
+ + ); + } + return ( {!(settings.merged.ui.hideBanner || config.getScreenReader()) && ( diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 1a25d2bb56..353e1ad535 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -4,9 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; import { render } from '../../test-utils/render.js'; import { Box, Text } from 'ink'; +import { useEffect } from 'react'; import { Composer } from './Composer.js'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; import { @@ -23,13 +24,18 @@ vi.mock('../contexts/VimModeContext.js', () => ({ vimMode: 'INSERT', })), })); -import { ApprovalMode } from '@google/gemini-cli-core'; +import { ApprovalMode, tokenLimit } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; import { StreamingState, ToolCallStatus } from '../types.js'; import { TransientMessageType } from '../../utils/events.js'; import type { LoadedSettings } from '../../config/settings.js'; import type { SessionMetrics } from '../contexts/SessionContext.js'; +const composerTestControls = vi.hoisted(() => ({ + suggestionsVisible: false, + isAlternateBuffer: false, +})); + // Mock child components vi.mock('./LoadingIndicator.js', () => ({ LoadingIndicator: ({ @@ -90,9 +96,19 @@ vi.mock('./DetailedMessagesDisplay.js', () => ({ })); vi.mock('./InputPrompt.js', () => ({ - InputPrompt: ({ placeholder }: { placeholder?: string }) => ( - InputPrompt: {placeholder} - ), + InputPrompt: ({ + placeholder, + onSuggestionsVisibilityChange, + }: { + placeholder?: string; + onSuggestionsVisibilityChange?: (visible: boolean) => void; + }) => { + useEffect(() => { + onSuggestionsVisibilityChange?.(composerTestControls.suggestionsVisible); + }, [onSuggestionsVisibilityChange]); + + return InputPrompt: {placeholder}; + }, calculatePromptWidths: vi.fn(() => ({ inputWidth: 80, suggestionsWidth: 40, @@ -100,6 +116,10 @@ vi.mock('./InputPrompt.js', () => ({ })), })); +vi.mock('../hooks/useAlternateBuffer.js', () => ({ + useAlternateBuffer: () => composerTestControls.isAlternateBuffer, +})); + vi.mock('./Footer.js', () => ({ Footer: () => Footer, })); @@ -154,15 +174,19 @@ const createMockUIState = (overrides: Partial = {}): UIState => ctrlDPressedOnce: false, showEscapePrompt: false, shortcutsHelpVisible: false, + cleanUiDetailsVisible: true, ideContextState: null, geminiMdFileCount: 0, renderMarkdown: true, filteredConsoleMessages: [], history: [], sessionStats: { + sessionId: 'test-session', + sessionStartTime: new Date(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metrics: {} as any, lastPromptTokenCount: 0, - sessionTokenCount: 0, - totalPrompts: 0, + promptCount: 0, }, branchName: 'main', debugMessage: '', @@ -187,6 +211,9 @@ const createMockUIActions = (): UIActions => handleFinalSubmit: vi.fn(), handleClearScreen: vi.fn(), setShellModeActive: vi.fn(), + setCleanUiDetailsVisible: vi.fn(), + toggleCleanUiDetailsVisible: vi.fn(), + revealCleanUiDetailsTemporarily: vi.fn(), onEscapePromptChange: vi.fn(), vimHandleInput: vi.fn(), setShortcutsHelpVisible: vi.fn(), @@ -233,6 +260,11 @@ const renderComposer = ( ); describe('Composer', () => { + beforeEach(() => { + composerTestControls.suggestionsVisible = false; + composerTestControls.isAlternateBuffer = false; + }); + afterEach(() => { vi.restoreAllMocks(); }); @@ -342,6 +374,7 @@ describe('Composer', () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, elapsedTime: 1, + cleanUiDetailsVisible: false, }); const { lastFrame } = renderComposer(uiState); @@ -514,6 +547,21 @@ describe('Composer', () => { }); describe('Input and Indicators', () => { + it('hides non-essential UI details in clean mode', () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + }); + + const { lastFrame } = renderComposer(uiState); + + const output = lastFrame(); + expect(output).toContain('ShortcutsHint'); + expect(output).toContain('InputPrompt'); + expect(output).not.toContain('Footer'); + expect(output).not.toContain('ApprovalModeIndicator'); + expect(output).not.toContain('ContextSummaryDisplay'); + }); + it('renders InputPrompt when input is active', () => { const uiState = createMockUIState({ isInputActive: true, @@ -582,6 +630,92 @@ describe('Composer', () => { expect(lastFrame()).not.toContain('raw markdown mode'); }); + + it.each([ + [ApprovalMode.YOLO, 'YOLO'], + [ApprovalMode.PLAN, 'plan'], + [ApprovalMode.AUTO_EDIT, 'auto edit'], + ])( + 'shows minimal mode badge "%s" when clean UI details are hidden', + (mode, label) => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + showApprovalModeIndicator: mode, + }); + + const { lastFrame } = renderComposer(uiState); + expect(lastFrame()).toContain(label); + }, + ); + + it('hides minimal mode badge while loading in clean mode', () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + streamingState: StreamingState.Responding, + elapsedTime: 1, + showApprovalModeIndicator: ApprovalMode.PLAN, + }); + + const { lastFrame } = renderComposer(uiState); + const output = lastFrame(); + expect(output).toContain('LoadingIndicator'); + expect(output).not.toContain('plan'); + expect(output).not.toContain('ShortcutsHint'); + }); + + it('hides minimal mode badge while action-required state is active', () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + showApprovalModeIndicator: ApprovalMode.PLAN, + customDialog: ( + + Prompt + + ), + }); + + const { lastFrame } = renderComposer(uiState); + const output = lastFrame(); + expect(output).not.toContain('plan'); + expect(output).not.toContain('ShortcutsHint'); + }); + + it('shows Esc rewind prompt in minimal mode without showing full UI', () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + showEscapePrompt: true, + history: [{ id: 1, type: 'user', text: 'msg' }], + }); + + const { lastFrame } = renderComposer(uiState); + const output = lastFrame(); + expect(output).toContain('ToastDisplay'); + expect(output).not.toContain('ContextSummaryDisplay'); + }); + + it('shows context usage bleed-through when over 60%', () => { + const model = 'gemini-2.5-pro'; + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + currentModel: model, + sessionStats: { + sessionId: 'test-session', + sessionStartTime: new Date(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metrics: {} as any, + lastPromptTokenCount: Math.floor(tokenLimit(model) * 0.7), + promptCount: 0, + }, + }); + const settings = createMockSettings({ + ui: { + footer: { hideContextPercentage: false }, + }, + }); + + const { lastFrame } = renderComposer(uiState, settings); + expect(lastFrame()).toContain('%'); + }); }); describe('Error Details Display', () => { @@ -680,7 +814,84 @@ describe('Composer', () => { }); it('keeps shortcuts hint visible when no action is required', () => { - const uiState = createMockUIState(); + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).toContain('ShortcutsHint'); + }); + + it('shows shortcuts hint when full UI details are visible', () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: true, + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).toContain('ShortcutsHint'); + }); + + it('hides shortcuts hint while loading in minimal mode', () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + streamingState: StreamingState.Responding, + elapsedTime: 1, + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).not.toContain('ShortcutsHint'); + }); + + it('shows shortcuts help in minimal mode when toggled on', () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + shortcutsHelpVisible: true, + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).toContain('ShortcutsHelp'); + }); + + it('hides shortcuts hint when suggestions are visible above input in alternate buffer', () => { + composerTestControls.isAlternateBuffer = true; + composerTestControls.suggestionsVisible = true; + + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + showApprovalModeIndicator: ApprovalMode.PLAN, + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).not.toContain('ShortcutsHint'); + expect(lastFrame()).not.toContain('plan'); + }); + + it('hides approval mode indicator when suggestions are visible above input in alternate buffer', () => { + composerTestControls.isAlternateBuffer = true; + composerTestControls.suggestionsVisible = true; + + const uiState = createMockUIState({ + cleanUiDetailsVisible: true, + showApprovalModeIndicator: ApprovalMode.YOLO, + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).not.toContain('ApprovalModeIndicator'); + }); + + it('keeps shortcuts hint when suggestions are visible below input in regular buffer', () => { + composerTestControls.isAlternateBuffer = false; + composerTestControls.suggestionsVisible = true; + + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + }); const { lastFrame } = renderComposer(uiState); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index b5b88b4e15..8101e7303c 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -5,7 +5,8 @@ */ import { useState, useEffect, useMemo } from 'react'; -import { Box, useIsScreenReaderEnabled } from 'ink'; +import { Box, Text, useIsScreenReaderEnabled } from 'ink'; +import { ApprovalMode, tokenLimit } from '@google/gemini-cli-core'; import { LoadingIndicator } from './LoadingIndicator.js'; import { StatusDisplay } from './StatusDisplay.js'; import { ToastDisplay, shouldShowToast } from './ToastDisplay.js'; @@ -19,6 +20,7 @@ import { InputPrompt } from './InputPrompt.js'; import { Footer } from './Footer.js'; import { ShowMoreLines } from './ShowMoreLines.js'; import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; +import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { HorizontalLine } from './shared/HorizontalLine.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; @@ -36,6 +38,7 @@ import { import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; import { TodoTray } from './messages/Todo.js'; import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; +import { theme } from '../semantic-colors.js'; export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const config = useConfig(); @@ -52,6 +55,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const isAlternateBuffer = useAlternateBuffer(); const { showApprovalModeIndicator } = uiState; + const showUiDetails = uiState.cleanUiDetailsVisible; const suggestionsPosition = isAlternateBuffer ? 'above' : 'below'; const hideContextSummary = suggestionsVisible && suggestionsPosition === 'above'; @@ -98,17 +102,60 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { uiState.shortcutsHelpVisible && uiState.streamingState === StreamingState.Idle && !hasPendingActionRequired; - const showShortcutsHint = - settings.merged.ui.showShortcutsHint && - uiState.streamingState === StreamingState.Idle && - !hasPendingActionRequired; const hasToast = shouldShowToast(uiState); const showLoadingIndicator = (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && uiState.streamingState === StreamingState.Responding && !hasPendingActionRequired; - const showApprovalIndicator = !uiState.shellModeActive; + const hideUiDetailsForSuggestions = + suggestionsVisible && suggestionsPosition === 'above'; + const showApprovalIndicator = + !uiState.shellModeActive && !hideUiDetailsForSuggestions; const showRawMarkdownIndicator = !uiState.renderMarkdown; + const modeBleedThrough = + showApprovalModeIndicator === ApprovalMode.YOLO + ? { text: 'YOLO', color: theme.status.error } + : showApprovalModeIndicator === ApprovalMode.PLAN + ? { text: 'plan', color: theme.status.success } + : showApprovalModeIndicator === ApprovalMode.AUTO_EDIT + ? { text: 'auto edit', color: theme.status.warning } + : null; + const hideMinimalModeHintWhileBusy = + !showUiDetails && (showLoadingIndicator || hasPendingActionRequired); + const minimalModeBleedThrough = hideMinimalModeHintWhileBusy + ? null + : modeBleedThrough; + const hasMinimalStatusBleedThrough = shouldShowToast(uiState); + const contextTokenLimit = + typeof uiState.currentModel === 'string' && uiState.currentModel.length > 0 + ? tokenLimit(uiState.currentModel) + : 0; + const showMinimalContextBleedThrough = + !settings.merged.ui.footer.hideContextPercentage && + typeof uiState.currentModel === 'string' && + uiState.currentModel.length > 0 && + contextTokenLimit > 0 && + uiState.sessionStats.lastPromptTokenCount / contextTokenLimit > 0.6; + const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions; + const showShortcutsHint = + settings.merged.ui.showShortcutsHint && + !hideShortcutsHintForSuggestions && + !hideMinimalModeHintWhileBusy && + !hasPendingActionRequired && + (!showUiDetails || !showLoadingIndicator); + const showMinimalModeBleedThrough = + !hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough); + const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator; + const showMinimalBleedThroughRow = + !showUiDetails && + (showMinimalModeBleedThrough || + hasMinimalStatusBleedThrough || + showMinimalContextBleedThrough); + const showMinimalMetaRow = + !showUiDetails && + (showMinimalInlineLoading || + showMinimalBleedThroughRow || + showShortcutsHint); return ( { /> )} - + {showUiDetails && ( + + )} - + {showUiDetails && } { alignItems="center" flexGrow={1} > - {showLoadingIndicator && ( + {showUiDetails && showLoadingIndicator && ( { flexDirection="column" alignItems={isNarrow ? 'flex-start' : 'flex-end'} > - {showShortcutsHint && } + {showUiDetails && showShortcutsHint && } - {showShortcutsHelp && } - - + {showMinimalMetaRow && ( - {hasToast ? ( - - ) : ( - !showLoadingIndicator && ( + + {showMinimalInlineLoading && ( + + )} + {showMinimalModeBleedThrough && minimalModeBleedThrough && ( + + ● {minimalModeBleedThrough.text} + + )} + {hasMinimalStatusBleedThrough && ( - {showApprovalIndicator && ( - - )} - {uiState.shellModeActive && ( - - - - )} - {showRawMarkdownIndicator && ( - - - - )} + - ) + )} + + {(showMinimalContextBleedThrough || showShortcutsHint) && ( + + {showMinimalContextBleedThrough && ( + + )} + {showShortcutsHint && ( + + + + )} + )} - + )} + {showShortcutsHelp && } + {showUiDetails && } + {showUiDetails && ( - {!showLoadingIndicator && ( - - )} + + {hasToast ? ( + + ) : ( + !showLoadingIndicator && ( + + {showApprovalIndicator && ( + + )} + {uiState.shellModeActive && ( + + + + )} + {showRawMarkdownIndicator && ( + + + + )} + + ) + )} + + + + {!showLoadingIndicator && ( + + )} + - + )} - {uiState.showErrorDetails && ( + {showUiDetails && uiState.showErrorDetails && ( { /> )} - {!settings.merged.ui.hideFooter && !isScreenReaderEnabled &&