Code review fixes for show question mark pr. (#18480)

This commit is contained in:
Jacob Richman
2026-02-06 22:35:14 -08:00
committed by GitHub
parent 6f1a5bf81d
commit a37844e5a1
11 changed files with 298 additions and 235 deletions

View File

@@ -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 {
@@ -598,4 +598,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');
});
});
});

View File

@@ -136,11 +136,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

View File

@@ -4028,6 +4028,55 @@ describe('InputPrompt', () => {
});
});
});
describe('shortcuts help visibility', () => {
it.each([
{
name: 'terminal paste event occurs',
input: '\x1b[200~pasted text\x1b[201~',
},
{
name: 'Ctrl+V (PASTE_CLIPBOARD) is pressed',
input: '\x16',
setupMocks: () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
vi.mocked(clipboardy.read).mockResolvedValue('clipboard text');
},
},
{
name: 'mouse right-click paste occurs',
input: '\x1b[<2;1;1m',
mouseEventsEnabled: true,
setupMocks: () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
vi.mocked(clipboardy.read).mockResolvedValue('clipboard text');
},
},
])(
'should close shortcuts help when a $name',
async ({ input, setupMocks, mouseEventsEnabled }) => {
setupMocks?.();
const setShortcutsHelpVisible = vi.fn();
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiState: { shortcutsHelpVisible: true },
uiActions: { setShortcutsHelpVisible },
mouseEventsEnabled,
},
);
await act(async () => {
stdin.write(input);
});
await waitFor(() => {
expect(setShortcutsHelpVisible).toHaveBeenCalledWith(false);
});
unmount();
},
);
});
});
function clean(str: string | undefined): string {

View File

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

View File

@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { ShortcutsHelp } from './ShortcutsHelp.js';
describe('ShortcutsHelp', () => {
const originalPlatform = process.platform;
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
vi.restoreAllMocks();
});
const testCases = [
{ name: 'wide', width: 100 },
{ name: 'narrow', width: 40 },
];
const platforms = [
{ name: 'mac', value: 'darwin' },
{ name: 'linux', value: 'linux' },
] as const;
it.each(
platforms.flatMap((platform) =>
testCases.map((testCase) => ({ ...testCase, platform })),
),
)(
'renders correctly in $name mode on $platform.name',
({ width, platform }) => {
Object.defineProperty(process, 'platform', {
value: platform.value,
});
const { lastFrame } = renderWithProviders(<ShortcutsHelp />, {
width,
});
expect(lastFrame()).toContain('shell mode');
expect(lastFrame()).toMatchSnapshot();
},
);
});

View File

@@ -6,227 +6,64 @@
import type React from 'react';
import { Box, Text } from 'ink';
import stringWidth from 'string-width';
import { theme } from '../semantic-colors.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { SectionHeader } from './shared/SectionHeader.js';
import { useUIState } from '../contexts/UIStateContext.js';
type ShortcutItem = {
key: string;
description: string;
};
const buildShortcutRows = (): ShortcutItem[][] => {
const buildShortcutItems = (): ShortcutItem[] => {
const isMac = process.platform === 'darwin';
const altLabel = isMac ? 'Option' : 'Alt';
return [
[
{ key: '!', description: 'shell 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+X', description: 'open external editor' },
],
{ key: '!', description: 'shell 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+X', description: 'open external editor' },
];
};
const renderItem = (item: ShortcutItem) => `${item.key} ${item.description}`;
const splitLongWord = (word: string, width: number) => {
if (width <= 0) return [''];
const parts: string[] = [];
let current = '';
for (const char of word) {
const next = current + char;
if (stringWidth(next) <= width) {
current = next;
continue;
}
if (current) {
parts.push(current);
}
current = char;
}
if (current) {
parts.push(current);
}
return parts.length > 0 ? parts : [''];
};
const wrapText = (text: string, width: number) => {
if (width <= 0) return [''];
const words = text.split(' ');
const lines: string[] = [];
let current = '';
for (const word of words) {
if (stringWidth(word) > width) {
if (current) {
lines.push(current);
current = '';
}
const chunks = splitLongWord(word, width);
for (const chunk of chunks) {
lines.push(chunk);
}
continue;
}
const next = current ? `${current} ${word}` : word;
if (stringWidth(next) <= width) {
current = next;
continue;
}
if (current) {
lines.push(current);
}
current = word;
}
if (current) {
lines.push(current);
}
return lines.length > 0 ? lines : [''];
};
const wrapDescription = (key: string, description: string, width: number) => {
const keyWidth = stringWidth(key);
const availableWidth = Math.max(1, width - keyWidth - 1);
const wrapped = wrapText(description, availableWidth);
return wrapped.length > 0 ? wrapped : [''];
};
const padToWidth = (text: string, width: number) => {
const padSize = Math.max(0, width - stringWidth(text));
return text + ' '.repeat(padSize);
};
const Shortcut: React.FC<{ item: ShortcutItem }> = ({ item }) => (
<Box flexDirection="row">
<Box flexShrink={0} marginRight={1}>
<Text color={theme.text.accent}>{item.key}</Text>
</Box>
<Box flexGrow={1}>
<Text color={theme.text.primary}>{item.description}</Text>
</Box>
</Box>
);
export const ShortcutsHelp: React.FC = () => {
const { columns: terminalWidth } = useTerminalSize();
const { terminalWidth } = useUIState();
const items = buildShortcutItems();
const isNarrow = isNarrowWidth(terminalWidth);
const shortcutRows = buildShortcutRows();
const leftInset = 1;
const rightInset = 2;
const gap = 2;
const contentWidth = Math.max(1, terminalWidth - leftInset - rightInset);
const columnWidth = Math.max(18, Math.floor((contentWidth - gap * 2) / 3));
const keyColor = theme.text.accent;
if (isNarrow) {
return (
<Box flexDirection="column">
<SectionHeader title="Shortcuts (for more, see /help)" />
{shortcutRows.flat().map((item, index) => {
const descriptionLines = wrapDescription(
item.key,
item.description,
contentWidth,
);
const keyWidth = stringWidth(item.key);
return descriptionLines.map((line, lineIndex) => {
const rightPadding = Math.max(
0,
contentWidth - (keyWidth + 1 + stringWidth(line)),
);
return (
<Text
key={`${item.key}-${index}-${lineIndex}`}
color={theme.text.primary}
>
{lineIndex === 0 ? (
<>
{' '.repeat(leftInset)}
<Text color={keyColor}>{item.key}</Text> {line}
{' '.repeat(rightPadding + rightInset)}
</>
) : (
`${' '.repeat(leftInset)}${padToWidth(
`${' '.repeat(keyWidth + 1)}${line}`,
contentWidth,
)}${' '.repeat(rightInset)}`
)}
</Text>
);
});
})}
</Box>
);
}
return (
<Box flexDirection="column">
<Box flexDirection="column" width="100%">
<SectionHeader title="Shortcuts (for more, see /help)" />
{shortcutRows.map((row, rowIndex) => {
const cellLines = row.map((item) =>
wrapText(renderItem(item), columnWidth),
);
const lineCount = Math.max(...cellLines.map((lines) => lines.length));
return Array.from({ length: lineCount }).map((_, lineIndex) => {
const segments = row.map((item, colIndex) => {
const lineText = cellLines[colIndex][lineIndex] ?? '';
const keyWidth = stringWidth(item.key);
if (lineIndex === 0) {
const rest = lineText.slice(item.key.length);
const restPadded = padToWidth(
rest,
Math.max(0, columnWidth - keyWidth),
);
return (
<Text key={`${item.key}-${colIndex}`}>
<Text color={keyColor}>{item.key}</Text>
{restPadded}
</Text>
);
}
const spacer = ' '.repeat(keyWidth);
const padded = padToWidth(`${spacer}${lineText}`, columnWidth);
return <Text key={`${item.key}-${colIndex}`}>{padded}</Text>;
});
return (
<Box
key={`row-${rowIndex}-line-${lineIndex}`}
width={terminalWidth}
flexDirection="row"
>
<Box width={leftInset}>
<Text>{' '.repeat(leftInset)}</Text>
</Box>
<Box width={columnWidth}>{segments[0]}</Box>
<Box width={gap}>
<Text>{' '.repeat(gap)}</Text>
</Box>
<Box width={columnWidth}>{segments[1]}</Box>
<Box width={gap}>
<Text>{' '.repeat(gap)}</Text>
</Box>
<Box width={columnWidth}>{segments[2]}</Box>
<Box width={rightInset}>
<Text>{' '.repeat(rightInset)}</Text>
</Box>
</Box>
);
});
})}
<Box flexDirection="row" flexWrap="wrap" paddingLeft={1} paddingRight={2}>
{items.map((item, index) => (
<Box
key={`${item.key}-${index}`}
width={isNarrow ? '100%' : '33%'}
paddingRight={isNarrow ? 0 : 2}
>
<Shortcut item={item} />
</Box>
))}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,41 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ShortcutsHelp > renders correctly in 'narrow' 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"
`;
exports[`ShortcutsHelp > renders correctly in 'narrow' 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"
`;
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"
`;
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"
`;

View File

@@ -5,21 +5,23 @@
*/
import type React from 'react';
import { Text } from 'ink';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Box } from 'ink';
import { theme } from '../../semantic-colors.js';
interface HorizontalLineProps {
width?: number;
color?: string;
}
export const HorizontalLine: React.FC<HorizontalLineProps> = ({
width,
color = theme.border.default,
}) => {
const { columns } = useTerminalSize();
const resolvedWidth = Math.max(1, width ?? columns);
return <Text color={color}>{'─'.repeat(resolvedWidth)}</Text>;
};
}) => (
<Box
width="100%"
borderStyle="single"
borderTop
borderBottom={false}
borderLeft={false}
borderRight={false}
borderColor={color}
/>
);

View File

@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { SectionHeader } from './SectionHeader.js';
describe('<SectionHeader />', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
{
description: 'renders correctly with a standard title',
title: 'My Header',
width: 40,
},
{
description:
'renders correctly when title is truncated but still shows dashes',
title: 'Very Long Header Title That Will Truncate',
width: 20,
},
{
description: 'renders correctly in a narrow container',
title: 'Narrow Container',
width: 25,
},
])('$description', ({ title, width }) => {
const { lastFrame, unmount } = renderWithProviders(
<SectionHeader title={title} />,
{ width },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});

View File

@@ -5,27 +5,25 @@
*/
import type React from 'react';
import { Text } from 'ink';
import stringWidth from 'string-width';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
const buildHeaderLine = (title: string, width: number) => {
const prefix = `── ${title} `;
const prefixWidth = stringWidth(prefix);
if (width <= prefixWidth) {
return prefix.slice(0, Math.max(0, width));
}
return prefix + '─'.repeat(Math.max(0, width - prefixWidth));
};
export const SectionHeader: React.FC<{ title: string; width?: number }> = ({
title,
width,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const resolvedWidth = Math.max(10, width ?? terminalWidth);
const text = buildHeaderLine(title, resolvedWidth);
return <Text color={theme.text.secondary}>{text}</Text>;
};
export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
<Box width="100%" flexDirection="row" overflow="hidden">
<Text color={theme.text.secondary} wrap="truncate-end">
{`── ${title}`}
</Text>
<Box
flexGrow={1}
flexShrink={0}
minWidth={2}
marginLeft={1}
borderStyle="single"
borderTop
borderBottom={false}
borderLeft={false}
borderRight={false}
borderColor={theme.text.secondary}
/>
</Box>
);

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<SectionHeader /> > 'renders correctly in a narrow contain…' 1`] = `"── Narrow Container ─────"`;
exports[`<SectionHeader /> > 'renders correctly when title is trunc…' 1`] = `"── Very Long Hea… ──"`;
exports[`<SectionHeader /> > 'renders correctly with a standard tit…' 1`] = `"── My Header ───────────────────────────"`;