diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 0f6f310637..73765dcf04 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -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: ( + + Test Dialog + Test Content + + ), + }); + + 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'); + }); + }); }); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 024b34216f..ee074c1c77 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -136,11 +136,11 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { flexDirection="column" alignItems={isNarrow ? 'flex-start' : 'flex-end'} > - + {!hasPendingActionRequired && } {uiState.shortcutsHelpVisible && } - + { }); }); }); + + 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( + , + { + 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 { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index df50365400..49c609ec9b 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -359,6 +359,9 @@ export const InputPrompt: React.FC = ({ // 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 = ({ } 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 = ({ } if (key.name === 'paste') { + if (shortcutsHelpVisible) { + setShortcutsHelpVisible(false); + } // Record paste time to prevent accidental auto-submission if (!isTerminalPasteTrusted(kittyProtocol.enabled)) { setRecentUnsafePasteTime(Date.now()); diff --git a/packages/cli/src/ui/components/ShortcutsHelp.test.tsx b/packages/cli/src/ui/components/ShortcutsHelp.test.tsx new file mode 100644 index 0000000000..e03f2c538b --- /dev/null +++ b/packages/cli/src/ui/components/ShortcutsHelp.test.tsx @@ -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(, { + width, + }); + expect(lastFrame()).toContain('shell mode'); + expect(lastFrame()).toMatchSnapshot(); + }, + ); +}); diff --git a/packages/cli/src/ui/components/ShortcutsHelp.tsx b/packages/cli/src/ui/components/ShortcutsHelp.tsx index 8efcb646a1..e18938fd62 100644 --- a/packages/cli/src/ui/components/ShortcutsHelp.tsx +++ b/packages/cli/src/ui/components/ShortcutsHelp.tsx @@ -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 }) => ( + + + {item.key} + + + {item.description} + + +); 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 ( - - - {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 ( - - {lineIndex === 0 ? ( - <> - {' '.repeat(leftInset)} - {item.key} {line} - {' '.repeat(rightPadding + rightInset)} - - ) : ( - `${' '.repeat(leftInset)}${padToWidth( - `${' '.repeat(keyWidth + 1)}${line}`, - contentWidth, - )}${' '.repeat(rightInset)}` - )} - - ); - }); - })} - - ); - } return ( - + - {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 ( - - {item.key} - {restPadded} - - ); - } - - const spacer = ' '.repeat(keyWidth); - const padded = padToWidth(`${spacer}${lineText}`, columnWidth); - return {padded}; - }); - - return ( - - - {' '.repeat(leftInset)} - - {segments[0]} - - {' '.repeat(gap)} - - {segments[1]} - - {' '.repeat(gap)} - - {segments[2]} - - {' '.repeat(rightInset)} - - - ); - }); - })} + + {items.map((item, index) => ( + + + + ))} + ); }; diff --git a/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap new file mode 100644 index 0000000000..692ac0c2d8 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap @@ -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" +`; diff --git a/packages/cli/src/ui/components/shared/HorizontalLine.tsx b/packages/cli/src/ui/components/shared/HorizontalLine.tsx index 3d9bacbb44..92935617a7 100644 --- a/packages/cli/src/ui/components/shared/HorizontalLine.tsx +++ b/packages/cli/src/ui/components/shared/HorizontalLine.tsx @@ -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 = ({ - width, color = theme.border.default, -}) => { - const { columns } = useTerminalSize(); - const resolvedWidth = Math.max(1, width ?? columns); - - return {'─'.repeat(resolvedWidth)}; -}; +}) => ( + +); diff --git a/packages/cli/src/ui/components/shared/SectionHeader.test.tsx b/packages/cli/src/ui/components/shared/SectionHeader.test.tsx new file mode 100644 index 0000000000..068e9ed9b6 --- /dev/null +++ b/packages/cli/src/ui/components/shared/SectionHeader.test.tsx @@ -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('', () => { + 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( + , + { width }, + ); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/shared/SectionHeader.tsx b/packages/cli/src/ui/components/shared/SectionHeader.tsx index 83a698afc1..daa41379fb 100644 --- a/packages/cli/src/ui/components/shared/SectionHeader.tsx +++ b/packages/cli/src/ui/components/shared/SectionHeader.tsx @@ -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}; -}; +export const SectionHeader: React.FC<{ title: string }> = ({ title }) => ( + + + {`── ${title}`} + + + +); diff --git a/packages/cli/src/ui/components/shared/__snapshots__/SectionHeader.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/SectionHeader.test.tsx.snap new file mode 100644 index 0000000000..7091e50ac9 --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/SectionHeader.test.tsx.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > 'renders correctly in a narrow contain…' 1`] = `"── Narrow Container ─────"`; + +exports[` > 'renders correctly when title is trunc…' 1`] = `"── Very Long Hea… ──"`; + +exports[` > 'renders correctly with a standard tit…' 1`] = `"── My Header ───────────────────────────"`;