feat(cli): prototype clean UI toggle and minimal-mode bleed-through (#18683)

This commit is contained in:
Dmitry Lyalin
2026-02-12 14:25:24 -05:00
committed by GitHub
parent b0cfbc6cd8
commit db00c5abf3
23 changed files with 872 additions and 151 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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.',

View File

@@ -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(),

View File

@@ -66,6 +66,7 @@ describe('App', () => {
const mockUIState: Partial<UIState> = {
streamingState: StreamingState.Idle,
cleanUiDetailsVisible: true,
quittingMessages: null,
dialogsVisible: false,
mainControlsRef: {

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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()) && (

View File

@@ -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);

View File

@@ -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>
);
};

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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: '',

View File

@@ -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) {

View File

@@ -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();
});
});

View File

@@ -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%'}

View File

@@ -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;

View File

@@ -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
"
`;

View File

@@ -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"
`;

View File

@@ -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;

View File

@@ -120,6 +120,7 @@ export interface UIState {
ctrlDPressedOnce: boolean;
showEscapePrompt: boolean;
shortcutsHelpVisible: boolean;
cleanUiDetailsVisible: boolean;
elapsedTime: number;
currentLoadingPhrase: string | undefined;
historyRemountKey: number;

View File

@@ -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,

View File

@@ -14,6 +14,7 @@ interface PersistentStateData {
defaultBannerShownCount?: Record<string, number>;
tipsShown?: number;
hasSeenScreenReaderNudge?: boolean;
focusUiEnabled?: boolean;
// Add other persistent state keys here as needed
}