mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-21 19:40:40 -07:00
feat(cli): prototype clean UI toggle and minimal-mode bleed-through (#18683)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -516,9 +516,9 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[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.',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -66,6 +66,7 @@ describe('App', () => {
|
||||
|
||||
const mockUIState: Partial<UIState> = {
|
||||
streamingState: StreamingState.Idle,
|
||||
cleanUiDetailsVisible: true,
|
||||
quittingMessages: null,
|
||||
dialogsVisible: false,
|
||||
mainControlsRef: {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<NodeJS.Timeout | null>(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,
|
||||
|
||||
@@ -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 (
|
||||
<Box flexDirection="column">
|
||||
<Header version={version} nightly={false} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{!(settings.merged.ui.hideBanner || config.getScreenReader()) && (
|
||||
|
||||
@@ -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 }) => (
|
||||
<Text>InputPrompt: {placeholder}</Text>
|
||||
),
|
||||
InputPrompt: ({
|
||||
placeholder,
|
||||
onSuggestionsVisibilityChange,
|
||||
}: {
|
||||
placeholder?: string;
|
||||
onSuggestionsVisibilityChange?: (visible: boolean) => void;
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
onSuggestionsVisibilityChange?.(composerTestControls.suggestionsVisible);
|
||||
}, [onSuggestionsVisibilityChange]);
|
||||
|
||||
return <Text>InputPrompt: {placeholder}</Text>;
|
||||
},
|
||||
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: () => <Text>Footer</Text>,
|
||||
}));
|
||||
@@ -154,15 +174,19 @@ const createMockUIState = (overrides: Partial<UIState> = {}): 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: (
|
||||
<Box>
|
||||
<Text>Prompt</Text>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
@@ -125,9 +172,11 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
|
||||
{showUiDetails && (
|
||||
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
|
||||
)}
|
||||
|
||||
<TodoTray />
|
||||
{showUiDetails && <TodoTray />}
|
||||
|
||||
<Box marginTop={1} width="100%" flexDirection="column">
|
||||
<Box
|
||||
@@ -143,7 +192,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
alignItems="center"
|
||||
flexGrow={1}
|
||||
>
|
||||
{showLoadingIndicator && (
|
||||
{showUiDetails && showLoadingIndicator && (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
thought={
|
||||
@@ -170,86 +219,169 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
{showShortcutsHint && <ShortcutsHint />}
|
||||
{showUiDetails && showShortcutsHint && <ShortcutsHint />}
|
||||
</Box>
|
||||
</Box>
|
||||
{showShortcutsHelp && <ShortcutsHelp />}
|
||||
<HorizontalLine />
|
||||
<Box
|
||||
justifyContent={
|
||||
settings.merged.ui.hideContextSummary
|
||||
? 'flex-start'
|
||||
: 'space-between'
|
||||
}
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{showMinimalMetaRow && (
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
flexGrow={1}
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{hasToast ? (
|
||||
<ToastDisplay />
|
||||
) : (
|
||||
!showLoadingIndicator && (
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
flexGrow={1}
|
||||
>
|
||||
{showMinimalInlineLoading && (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
thought={
|
||||
uiState.streamingState ===
|
||||
StreamingState.WaitingForConfirmation ||
|
||||
config.getAccessibility()?.enableLoadingPhrases === false
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
config.getAccessibility()?.enableLoadingPhrases === false
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
thoughtLabel={
|
||||
inlineThinkingMode === 'full' ? 'Thinking ...' : undefined
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
/>
|
||||
)}
|
||||
{showMinimalModeBleedThrough && minimalModeBleedThrough && (
|
||||
<Text color={minimalModeBleedThrough.color}>
|
||||
● {minimalModeBleedThrough.text}
|
||||
</Text>
|
||||
)}
|
||||
{hasMinimalStatusBleedThrough && (
|
||||
<Box
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
marginLeft={
|
||||
showMinimalInlineLoading || showMinimalModeBleedThrough
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
{showApprovalIndicator && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
isPlanEnabled={config.isPlanEnabled()}
|
||||
/>
|
||||
)}
|
||||
{uiState.shellModeActive && (
|
||||
<Box
|
||||
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
|
||||
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
|
||||
>
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{showRawMarkdownIndicator && (
|
||||
<Box
|
||||
marginLeft={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
marginTop={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
<ToastDisplay />
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
{(showMinimalContextBleedThrough || showShortcutsHint) && (
|
||||
<Box
|
||||
marginTop={isNarrow && showMinimalBleedThroughRow ? 1 : 0}
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
{showMinimalContextBleedThrough && (
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
|
||||
model={uiState.currentModel}
|
||||
terminalWidth={uiState.terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{showShortcutsHint && (
|
||||
<Box
|
||||
marginLeft={
|
||||
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
|
||||
}
|
||||
marginTop={
|
||||
showMinimalContextBleedThrough && isNarrow ? 1 : 0
|
||||
}
|
||||
>
|
||||
<ShortcutsHint />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
)}
|
||||
{showShortcutsHelp && <ShortcutsHelp />}
|
||||
{showUiDetails && <HorizontalLine />}
|
||||
{showUiDetails && (
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
justifyContent={
|
||||
settings.merged.ui.hideContextSummary
|
||||
? 'flex-start'
|
||||
: 'space-between'
|
||||
}
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{!showLoadingIndicator && (
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
)}
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
flexGrow={1}
|
||||
>
|
||||
{hasToast ? (
|
||||
<ToastDisplay />
|
||||
) : (
|
||||
!showLoadingIndicator && (
|
||||
<Box
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{showApprovalIndicator && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
isPlanEnabled={config.isPlanEnabled()}
|
||||
/>
|
||||
)}
|
||||
{uiState.shellModeActive && (
|
||||
<Box
|
||||
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
|
||||
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
|
||||
>
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{showRawMarkdownIndicator && (
|
||||
<Box
|
||||
marginLeft={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
marginTop={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
{!showLoadingIndicator && (
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{uiState.showErrorDetails && (
|
||||
{showUiDetails && uiState.showErrorDetails && (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
<DetailedMessagesDisplay
|
||||
@@ -301,7 +433,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!settings.merged.ui.hideFooter && !isScreenReaderEnabled && <Footer />}
|
||||
{showUiDetails &&
|
||||
!settings.merged.ui.hideFooter &&
|
||||
!isScreenReaderEnabled && <Footer />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -149,8 +149,14 @@ describe('InputPrompt', () => {
|
||||
);
|
||||
const mockedUseKittyKeyboardProtocol = vi.mocked(useKittyKeyboardProtocol);
|
||||
const mockSetEmbeddedShellFocused = vi.fn();
|
||||
const mockSetCleanUiDetailsVisible = vi.fn();
|
||||
const mockToggleCleanUiDetailsVisible = vi.fn();
|
||||
const mockRevealCleanUiDetailsTemporarily = vi.fn();
|
||||
const uiActions = {
|
||||
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
|
||||
setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
|
||||
toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
|
||||
revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -2945,29 +2951,29 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tab focus toggle', () => {
|
||||
describe('Tab clean UI toggle', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'should toggle focus in on Tab when no suggestions or ghost text',
|
||||
name: 'should toggle clean UI details on double-Tab when no suggestions or ghost text',
|
||||
showSuggestions: false,
|
||||
ghostText: '',
|
||||
suggestions: [],
|
||||
expectedFocusToggle: true,
|
||||
expectedUiToggle: true,
|
||||
},
|
||||
{
|
||||
name: 'should accept ghost text and NOT toggle focus on Tab',
|
||||
name: 'should accept ghost text and NOT toggle clean UI details on Tab',
|
||||
showSuggestions: false,
|
||||
ghostText: 'ghost text',
|
||||
suggestions: [],
|
||||
expectedFocusToggle: false,
|
||||
expectedUiToggle: false,
|
||||
expectedAcceptCall: true,
|
||||
},
|
||||
{
|
||||
name: 'should NOT toggle focus on Tab when suggestions are present',
|
||||
name: 'should NOT toggle clean UI details on Tab when suggestions are present',
|
||||
showSuggestions: true,
|
||||
ghostText: '',
|
||||
suggestions: [{ label: 'test', value: 'test' }],
|
||||
expectedFocusToggle: false,
|
||||
expectedUiToggle: false,
|
||||
},
|
||||
])(
|
||||
'$name',
|
||||
@@ -2975,7 +2981,7 @@ describe('InputPrompt', () => {
|
||||
showSuggestions,
|
||||
ghostText,
|
||||
suggestions,
|
||||
expectedFocusToggle,
|
||||
expectedUiToggle,
|
||||
expectedAcceptCall,
|
||||
}) => {
|
||||
const mockAccept = vi.fn();
|
||||
@@ -2997,21 +3003,24 @@ describe('InputPrompt', () => {
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
uiState: { activePtyId: 1 },
|
||||
uiState: {},
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\t');
|
||||
if (expectedUiToggle) {
|
||||
stdin.write('\t');
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
if (expectedFocusToggle) {
|
||||
expect(uiActions.setEmbeddedShellFocused).toHaveBeenCalledWith(
|
||||
true,
|
||||
);
|
||||
if (expectedUiToggle) {
|
||||
expect(uiActions.toggleCleanUiDetailsVisible).toHaveBeenCalled();
|
||||
} else {
|
||||
expect(uiActions.setEmbeddedShellFocused).not.toHaveBeenCalled();
|
||||
expect(
|
||||
uiActions.toggleCleanUiDetailsVisible,
|
||||
).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
if (expectedAcceptCall) {
|
||||
@@ -3021,6 +3030,75 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('should not reveal clean UI details on Shift+Tab when hidden', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
promptCompletion: {
|
||||
text: '',
|
||||
accept: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
isLoading: false,
|
||||
isActive: false,
|
||||
markSelected: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
uiState: { activePtyId: 1, cleanUiDetailsVisible: false },
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[Z');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
uiActions.revealCleanUiDetailsTemporarily,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should toggle clean UI details on double-Tab by default', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
promptCompletion: {
|
||||
text: '',
|
||||
accept: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
isLoading: false,
|
||||
isActive: false,
|
||||
markSelected: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiActions,
|
||||
uiState: {},
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\t');
|
||||
stdin.write('\t');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uiActions.toggleCleanUiDetailsVisible).toHaveBeenCalled();
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mouse interaction', () => {
|
||||
|
||||
@@ -144,6 +144,8 @@ export function isLargePaste(text: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
const DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS = 350;
|
||||
|
||||
/**
|
||||
* Attempt to toggle expansion of a paste placeholder in the buffer.
|
||||
* Returns true if a toggle action was performed or hint was shown, false otherwise.
|
||||
@@ -211,7 +213,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const { merged: settings } = useSettings();
|
||||
const kittyProtocol = useKittyKeyboardProtocol();
|
||||
const isShellFocused = useShellFocusState();
|
||||
const { setEmbeddedShellFocused, setShortcutsHelpVisible } = useUIActions();
|
||||
const {
|
||||
setEmbeddedShellFocused,
|
||||
setShortcutsHelpVisible,
|
||||
toggleCleanUiDetailsVisible,
|
||||
} = useUIActions();
|
||||
const {
|
||||
terminalWidth,
|
||||
activePtyId,
|
||||
@@ -223,6 +229,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
} = useUIState();
|
||||
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
||||
const escPressCount = useRef(0);
|
||||
const lastPlainTabPressTimeRef = useRef<number | null>(null);
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [recentUnsafePasteTime, setRecentUnsafePasteTime] = useState<
|
||||
@@ -624,6 +631,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
const isPlainTab =
|
||||
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
|
||||
const hasTabCompletionInteraction =
|
||||
completion.showSuggestions ||
|
||||
Boolean(completion.promptCompletion.text) ||
|
||||
reverseSearchActive ||
|
||||
commandSearchActive;
|
||||
if (isPlainTab) {
|
||||
if (!hasTabCompletionInteraction) {
|
||||
const now = Date.now();
|
||||
const isDoubleTabPress =
|
||||
lastPlainTabPressTimeRef.current !== null &&
|
||||
now - lastPlainTabPressTimeRef.current <=
|
||||
DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS;
|
||||
if (isDoubleTabPress) {
|
||||
lastPlainTabPressTimeRef.current = null;
|
||||
toggleCleanUiDetailsVisible();
|
||||
return true;
|
||||
}
|
||||
lastPlainTabPressTimeRef.current = now;
|
||||
} else {
|
||||
lastPlainTabPressTimeRef.current = null;
|
||||
}
|
||||
} else {
|
||||
lastPlainTabPressTimeRef.current = null;
|
||||
}
|
||||
|
||||
if (key.name === 'paste') {
|
||||
if (shortcutsHelpVisible) {
|
||||
setShortcutsHelpVisible(false);
|
||||
@@ -1172,6 +1206,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
kittyProtocol.enabled,
|
||||
shortcutsHelpVisible,
|
||||
setShortcutsHelpVisible,
|
||||
toggleCleanUiDetailsVisible,
|
||||
tryLoadQueuedMessages,
|
||||
setBannerVisible,
|
||||
onSubmit,
|
||||
|
||||
@@ -9,11 +9,15 @@ import { waitFor } from '../../test-utils/async.js';
|
||||
import { MainContent } from './MainContent.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { act, useState, type JSX } from 'react';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
import { SHELL_COMMAND_NAME } from '../constants.js';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import {
|
||||
UIStateContext,
|
||||
useUIState,
|
||||
type UIState,
|
||||
} from '../contexts/UIStateContext.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../contexts/SettingsContext.js', async () => {
|
||||
@@ -45,7 +49,9 @@ vi.mock('../hooks/useAlternateBuffer.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('./AppHeader.js', () => ({
|
||||
AppHeader: () => <Text>AppHeader</Text>,
|
||||
AppHeader: ({ showDetails = true }: { showDetails?: boolean }) => (
|
||||
<Text>{showDetails ? 'AppHeader(full)' : 'AppHeader(minimal)'}</Text>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./ShowMoreLines.js', () => ({
|
||||
@@ -58,7 +64,7 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
||||
renderItem,
|
||||
}: {
|
||||
data: unknown[];
|
||||
renderItem: (props: { item: unknown }) => React.JSX.Element;
|
||||
renderItem: (props: { item: unknown }) => JSX.Element;
|
||||
}) => (
|
||||
<Box flexDirection="column">
|
||||
<Text>ScrollableList</Text>
|
||||
@@ -87,6 +93,7 @@ describe('MainContent', () => {
|
||||
activePtyId: undefined,
|
||||
embeddedShellFocused: false,
|
||||
historyRemountKey: 0,
|
||||
cleanUiDetailsVisible: true,
|
||||
bannerData: { defaultText: '', warningText: '' },
|
||||
bannerVisible: false,
|
||||
copyModeEnabled: false,
|
||||
@@ -101,7 +108,7 @@ describe('MainContent', () => {
|
||||
const { lastFrame } = renderWithProviders(<MainContent />, {
|
||||
uiState: defaultMockUiState as Partial<UIState>,
|
||||
});
|
||||
await waitFor(() => expect(lastFrame()).toContain('AppHeader'));
|
||||
await waitFor(() => expect(lastFrame()).toContain('AppHeader(full)'));
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Hello');
|
||||
@@ -116,11 +123,81 @@ describe('MainContent', () => {
|
||||
await waitFor(() => expect(lastFrame()).toContain('ScrollableList'));
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('AppHeader');
|
||||
expect(output).toContain('AppHeader(full)');
|
||||
expect(output).toContain('Hello');
|
||||
expect(output).toContain('Hi there');
|
||||
});
|
||||
|
||||
it('renders minimal header in minimal mode (alternate buffer)', async () => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(true);
|
||||
|
||||
const { lastFrame } = renderWithProviders(<MainContent />, {
|
||||
uiState: {
|
||||
...defaultMockUiState,
|
||||
cleanUiDetailsVisible: false,
|
||||
} as Partial<UIState>,
|
||||
});
|
||||
await waitFor(() => expect(lastFrame()).toContain('Hello'));
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('AppHeader(minimal)');
|
||||
expect(output).not.toContain('AppHeader(full)');
|
||||
expect(output).toContain('Hello');
|
||||
});
|
||||
|
||||
it('restores full header details after toggle in alternate buffer mode', async () => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(true);
|
||||
|
||||
let setShowDetails: ((visible: boolean) => void) | undefined;
|
||||
const ToggleHarness = () => {
|
||||
const outerState = useUIState();
|
||||
const [showDetails, setShowDetailsState] = useState(
|
||||
outerState.cleanUiDetailsVisible,
|
||||
);
|
||||
setShowDetails = setShowDetailsState;
|
||||
|
||||
return (
|
||||
<UIStateContext.Provider
|
||||
value={{ ...outerState, cleanUiDetailsVisible: showDetails }}
|
||||
>
|
||||
<MainContent />
|
||||
</UIStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(<ToggleHarness />, {
|
||||
uiState: {
|
||||
...defaultMockUiState,
|
||||
cleanUiDetailsVisible: false,
|
||||
} as Partial<UIState>,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(lastFrame()).toContain('AppHeader(minimal)'));
|
||||
if (!setShowDetails) {
|
||||
throw new Error('setShowDetails was not initialized');
|
||||
}
|
||||
const setShowDetailsSafe = setShowDetails;
|
||||
|
||||
act(() => {
|
||||
setShowDetailsSafe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(lastFrame()).toContain('AppHeader(full)'));
|
||||
});
|
||||
|
||||
it('always renders full header details in normal buffer mode', async () => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(false);
|
||||
const { lastFrame } = renderWithProviders(<MainContent />, {
|
||||
uiState: {
|
||||
...defaultMockUiState,
|
||||
cleanUiDetailsVisible: false,
|
||||
} as Partial<UIState>,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(lastFrame()).toContain('AppHeader(full)'));
|
||||
expect(lastFrame()).not.toContain('AppHeader(minimal)');
|
||||
});
|
||||
|
||||
it('does not constrain height in alternate buffer mode', async () => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(true);
|
||||
const { lastFrame } = renderWithProviders(<MainContent />, {
|
||||
@@ -129,7 +206,9 @@ describe('MainContent', () => {
|
||||
await waitFor(() => expect(lastFrame()).toContain('Hello'));
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toMatchSnapshot();
|
||||
expect(output).toContain('AppHeader(full)');
|
||||
expect(output).toContain('Hello');
|
||||
expect(output).toContain('Hi there');
|
||||
});
|
||||
|
||||
describe('MainContent Tool Output Height Logic', () => {
|
||||
@@ -210,6 +289,7 @@ describe('MainContent', () => {
|
||||
isEditorDialogOpen: false,
|
||||
slashCommands: [],
|
||||
historyRemountKey: 0,
|
||||
cleanUiDetailsVisible: true,
|
||||
bannerData: {
|
||||
defaultText: '',
|
||||
warningText: '',
|
||||
|
||||
@@ -48,7 +48,9 @@ export const MainContent = () => {
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
availableTerminalHeight,
|
||||
cleanUiDetailsVisible,
|
||||
} = uiState;
|
||||
const showHeaderDetails = cleanUiDetailsVisible;
|
||||
|
||||
const historyItems = useMemo(
|
||||
() =>
|
||||
@@ -120,7 +122,13 @@ export const MainContent = () => {
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: (typeof virtualizedData)[number] }) => {
|
||||
if (item.type === 'header') {
|
||||
return <MemoizedAppHeader key="app-header" version={version} />;
|
||||
return (
|
||||
<MemoizedAppHeader
|
||||
key="app-header"
|
||||
version={version}
|
||||
showDetails={showHeaderDetails}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'history') {
|
||||
return (
|
||||
<MemoizedHistoryItemDisplay
|
||||
@@ -137,7 +145,13 @@ export const MainContent = () => {
|
||||
return pendingItems;
|
||||
}
|
||||
},
|
||||
[version, mainAreaWidth, uiState.slashCommands, pendingItems],
|
||||
[
|
||||
showHeaderDetails,
|
||||
version,
|
||||
mainAreaWidth,
|
||||
uiState.slashCommands,
|
||||
pendingItems,
|
||||
],
|
||||
);
|
||||
|
||||
if (isAlternateBuffer) {
|
||||
|
||||
@@ -46,4 +46,10 @@ describe('ShortcutsHelp', () => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
|
||||
it('always shows Tab Tab focus UI shortcut', () => {
|
||||
const rendered = renderWithProviders(<ShortcutsHelp />);
|
||||
expect(rendered.lastFrame()).toContain('Tab Tab');
|
||||
rendered.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,13 +22,14 @@ const buildShortcutItems = (): ShortcutItem[] => {
|
||||
|
||||
return [
|
||||
{ key: '!', description: 'shell mode' },
|
||||
{ key: '@', description: 'select file or folder' },
|
||||
{ key: 'Esc Esc', description: 'clear & rewind' },
|
||||
{ key: 'Tab Tab', description: 'focus UI' },
|
||||
{ key: 'Ctrl+Y', description: 'YOLO mode' },
|
||||
{ key: 'Shift+Tab', description: 'cycle mode' },
|
||||
{ key: 'Ctrl+V', description: 'paste images' },
|
||||
{ key: '@', description: 'select file or folder' },
|
||||
{ key: 'Ctrl+Y', description: 'YOLO mode' },
|
||||
{ key: 'Ctrl+R', description: 'reverse-search history' },
|
||||
{ key: 'Esc Esc', description: 'clear prompt / rewind' },
|
||||
{ key: `${altLabel}+M`, description: 'raw markdown mode' },
|
||||
{ key: 'Ctrl+R', description: 'reverse-search history' },
|
||||
{ key: 'Ctrl+X', description: 'open external editor' },
|
||||
];
|
||||
};
|
||||
@@ -46,15 +47,29 @@ const Shortcut: React.FC<{ item: ShortcutItem }> = ({ item }) => (
|
||||
|
||||
export const ShortcutsHelp: React.FC = () => {
|
||||
const { terminalWidth } = useUIState();
|
||||
const items = buildShortcutItems();
|
||||
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const items = buildShortcutItems();
|
||||
const itemsForDisplay = isNarrow
|
||||
? items
|
||||
: [
|
||||
// Keep first column stable: !, @, Esc Esc, Tab Tab.
|
||||
items[0],
|
||||
items[5],
|
||||
items[6],
|
||||
items[1],
|
||||
items[4],
|
||||
items[7],
|
||||
items[2],
|
||||
items[8],
|
||||
items[9],
|
||||
items[3],
|
||||
];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<SectionHeader title="Shortcuts (for more, see /help)" />
|
||||
<Box flexDirection="row" flexWrap="wrap" paddingLeft={1} paddingRight={2}>
|
||||
{items.map((item, index) => (
|
||||
{itemsForDisplay.map((item, index) => (
|
||||
<Box
|
||||
key={`${item.key}-${index}`}
|
||||
width={isNarrow ? '100%' : '33%'}
|
||||
|
||||
@@ -10,7 +10,12 @@ import { theme } from '../semantic-colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
export const ShortcutsHint: React.FC = () => {
|
||||
const { shortcutsHelpVisible } = useUIState();
|
||||
const { cleanUiDetailsVisible, shortcutsHelpVisible } = useUIState();
|
||||
|
||||
if (!cleanUiDetailsVisible) {
|
||||
return <Text color={theme.text.secondary}> press tab twice for more </Text>;
|
||||
}
|
||||
|
||||
const highlightColor = shortcutsHelpVisible
|
||||
? theme.text.accent
|
||||
: theme.text.secondary;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Focused shell should expand' 1`] = `
|
||||
"ScrollableList
|
||||
AppHeader
|
||||
AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
@@ -33,7 +33,7 @@ ShowMoreLines"
|
||||
|
||||
exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Unfocused shell' 1`] = `
|
||||
"ScrollableList
|
||||
AppHeader
|
||||
AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
@@ -57,7 +57,7 @@ ShowMoreLines"
|
||||
`;
|
||||
|
||||
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Constrained height' 1`] = `
|
||||
"AppHeader
|
||||
"AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
@@ -81,7 +81,7 @@ ShowMoreLines"
|
||||
`;
|
||||
|
||||
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unconstrained height' 1`] = `
|
||||
"AppHeader
|
||||
"AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
@@ -103,14 +103,3 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
ShowMoreLines"
|
||||
`;
|
||||
|
||||
exports[`MainContent > does not constrain height in alternate buffer mode 1`] = `
|
||||
"ScrollableList
|
||||
AppHeader
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Hello
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
✦ Hi there
|
||||
ShowMoreLines
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -3,39 +3,43 @@
|
||||
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────
|
||||
! shell mode
|
||||
@ select file or folder
|
||||
Esc Esc clear & rewind
|
||||
Tab Tab focus UI
|
||||
Ctrl+Y YOLO mode
|
||||
Shift+Tab cycle mode
|
||||
Ctrl+V paste images
|
||||
@ select file or folder
|
||||
Ctrl+Y YOLO mode
|
||||
Ctrl+R reverse-search history
|
||||
Esc Esc clear prompt / rewind
|
||||
Alt+M raw markdown mode
|
||||
Ctrl+R reverse-search history
|
||||
Ctrl+X open external editor"
|
||||
`;
|
||||
|
||||
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────
|
||||
! shell mode
|
||||
@ select file or folder
|
||||
Esc Esc clear & rewind
|
||||
Tab Tab focus UI
|
||||
Ctrl+Y YOLO mode
|
||||
Shift+Tab cycle mode
|
||||
Ctrl+V paste images
|
||||
@ select file or folder
|
||||
Ctrl+Y YOLO mode
|
||||
Ctrl+R reverse-search history
|
||||
Esc Esc clear prompt / rewind
|
||||
Option+M raw markdown mode
|
||||
Ctrl+R reverse-search history
|
||||
Ctrl+X open external editor"
|
||||
`;
|
||||
|
||||
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
|
||||
! shell mode Shift+Tab cycle mode Ctrl+V paste images
|
||||
@ select file or folder Ctrl+Y YOLO mode Ctrl+R reverse-search history
|
||||
Esc Esc clear prompt / rewind Alt+M raw markdown mode Ctrl+X open external editor"
|
||||
@ select file or folder Ctrl+Y YOLO mode Alt+M raw markdown mode
|
||||
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
|
||||
Tab Tab focus UI"
|
||||
`;
|
||||
|
||||
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'mac' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
|
||||
! shell mode Shift+Tab cycle mode Ctrl+V paste images
|
||||
@ select file or folder Ctrl+Y YOLO mode Ctrl+R reverse-search history
|
||||
Esc Esc clear prompt / rewind Option+M raw markdown mode Ctrl+X open external editor"
|
||||
@ select file or folder Ctrl+Y YOLO mode Option+M raw markdown mode
|
||||
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
|
||||
Tab Tab focus UI"
|
||||
`;
|
||||
|
||||
@@ -68,6 +68,10 @@ export interface UIActions {
|
||||
handleApiKeyCancel: () => void;
|
||||
setBannerVisible: (visible: boolean) => void;
|
||||
setShortcutsHelpVisible: (visible: boolean) => void;
|
||||
setCleanUiDetailsVisible: (visible: boolean) => void;
|
||||
toggleCleanUiDetailsVisible: () => void;
|
||||
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
|
||||
handleWarning: (message: string) => void;
|
||||
setEmbeddedShellFocused: (value: boolean) => void;
|
||||
dismissBackgroundShell: (pid: number) => void;
|
||||
setActiveBackgroundShellPid: (pid: number) => void;
|
||||
|
||||
@@ -120,6 +120,7 @@ export interface UIState {
|
||||
ctrlDPressedOnce: boolean;
|
||||
showEscapePrompt: boolean;
|
||||
shortcutsHelpVisible: boolean;
|
||||
cleanUiDetailsVisible: boolean;
|
||||
elapsedTime: number;
|
||||
currentLoadingPhrase: string | undefined;
|
||||
historyRemountKey: number;
|
||||
|
||||
@@ -370,7 +370,7 @@ describe('keyMatchers', () => {
|
||||
{
|
||||
command: Command.FOCUS_SHELL_INPUT,
|
||||
positive: [createKey('tab')],
|
||||
negative: [createKey('f', { ctrl: true }), createKey('f')],
|
||||
negative: [createKey('f6'), createKey('f', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.TOGGLE_YOLO,
|
||||
|
||||
@@ -14,6 +14,7 @@ interface PersistentStateData {
|
||||
defaultBannerShownCount?: Record<string, number>;
|
||||
tipsShown?: number;
|
||||
hasSeenScreenReaderNudge?: boolean;
|
||||
focusUiEnabled?: boolean;
|
||||
// Add other persistent state keys here as needed
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user