Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion packages/cli/src/ui/components/Composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { describe, it, expect, vi } from 'vitest';
import { render } from '../../test-utils/render.js';
import { Text } from 'ink';
import { Box, Text } from 'ink';
import { Composer } from './Composer.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import {
Expand Down Expand Up @@ -589,4 +589,29 @@ describe('Composer', () => {
);
});
});

describe('Shortcuts Hint', () => {
it('hides shortcuts hint when a action is required (e.g. dialog is open)', () => {
const uiState = createMockUIState({
customDialog: (
<Box>
<Text>Test Dialog</Text>
<Text>Test Content</Text>
</Box>
),
});

const { lastFrame } = renderComposer(uiState);

expect(lastFrame()).not.toContain('ShortcutsHint');
});

it('keeps shortcuts hint visible when no action is required', () => {
const uiState = createMockUIState();

const { lastFrame } = renderComposer(uiState);

expect(lastFrame()).toContain('ShortcutsHint');
});
});
});
4 changes: 2 additions & 2 deletions packages/cli/src/ui/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,11 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
>
<ShortcutsHint />
{!hasPendingActionRequired && <ShortcutsHint />}
</Box>
</Box>
{uiState.shortcutsHelpVisible && <ShortcutsHelp />}
<HorizontalLine width={uiState.terminalWidth} />
<HorizontalLine />
<Box
justifyContent={
settings.merged.ui.hideContextSummary
Expand Down
72 changes: 72 additions & 0 deletions packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4028,6 +4028,78 @@ describe('InputPrompt', () => {
});
});
});

describe('shortcuts help visibility', () => {
it('should close shortcuts help when a terminal paste event occurs', async () => {
const setShortcutsHelpVisible = vi.fn();
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiState: { shortcutsHelpVisible: true },
uiActions: { setShortcutsHelpVisible },
},
);

await act(async () => {
// Simulate terminal paste event
stdin.write('\x1b[200~pasted text\x1b[201~');
});

await waitFor(() => {
expect(setShortcutsHelpVisible).toHaveBeenCalledWith(false);
});
unmount();
});

it('should close shortcuts help when Ctrl+V (PASTE_CLIPBOARD) is pressed', async () => {
const setShortcutsHelpVisible = vi.fn();
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
vi.mocked(clipboardy.read).mockResolvedValue('clipboard text');

const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiState: { shortcutsHelpVisible: true },
uiActions: { setShortcutsHelpVisible },
},
);

await act(async () => {
// Send Ctrl+V (\x16)
stdin.write('\x16');
});

await waitFor(() => {
expect(setShortcutsHelpVisible).toHaveBeenCalledWith(false);
});
unmount();
});

it('should close shortcuts help when mouse right-click paste occurs', async () => {
const setShortcutsHelpVisible = vi.fn();
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
vi.mocked(clipboardy.read).mockResolvedValue('clipboard text');

const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiState: { shortcutsHelpVisible: true },
uiActions: { setShortcutsHelpVisible },
mouseEventsEnabled: true,
},
);

await act(async () => {
// Simulate right-click release (SGR format: \x1b[<2;col;row m)
stdin.write('\x1b[<2;1;1m');
});

await waitFor(() => {
expect(setShortcutsHelpVisible).toHaveBeenCalledWith(false);
});
unmount();
});
});
});

function clean(str: string | undefined): string {
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({

// Handle clipboard image pasting with Ctrl+V
const handleClipboardPaste = useCallback(async () => {
if (shortcutsHelpVisible) {
setShortcutsHelpVisible(false);
}
try {
if (await clipboardHasImage()) {
const imagePath = await saveClipboardImage(config.getTargetDir());
Expand Down Expand Up @@ -403,7 +406,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} catch (error) {
debugLogger.error('Error handling paste:', error);
}
}, [buffer, config, stdout, settings]);
}, [
buffer,
config,
stdout,
settings,
shortcutsHelpVisible,
setShortcutsHelpVisible,
]);

useMouseClick(
innerBoxRef,
Expand Down Expand Up @@ -553,6 +563,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}

if (key.name === 'paste') {
if (shortcutsHelpVisible) {
setShortcutsHelpVisible(false);
}
// Record paste time to prevent accidental auto-submission
if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
setRecentUnsafePasteTime(Date.now());
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/ui/components/ShortcutsHelp.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { ShortcutsHelp } from './ShortcutsHelp.js';

describe('ShortcutsHelp', () => {
it('renders correctly in wide mode', async () => {
const { lastFrame } = renderWithProviders(<ShortcutsHelp />, {
width: 100,
});
// Wait for it to render
await waitFor(() => expect(lastFrame()).toContain('shell mode'));
expect(lastFrame()).toMatchSnapshot();
});

it('renders correctly in narrow mode', async () => {
const { lastFrame } = renderWithProviders(<ShortcutsHelp />, { width: 40 });
// Wait for it to render
await waitFor(() => expect(lastFrame()).toContain('shell mode'));
expect(lastFrame()).toMatchSnapshot();
});
});
Loading
Loading