diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index d9094c6ae5a..364c3a53c25 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 { @@ -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: ( + + 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 57afdde9432..7160a575906 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -139,11 +139,11 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { flexDirection="column" alignItems={isNarrow ? 'flex-start' : 'flex-end'} > - + {!hasPendingActionRequired && } {uiState.shortcutsHelpVisible && } - + { }); }); }); + + describe('shortcuts help visibility', () => { + it('should close shortcuts help when a terminal paste event occurs', async () => { + const setShortcutsHelpVisible = vi.fn(); + const { stdin, unmount } = renderWithProviders( + , + { + 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( + , + { + 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( + , + { + 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 { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index df50365400e..49c609ec9b9 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 00000000000..6482e2d4c6c --- /dev/null +++ b/packages/cli/src/ui/components/ShortcutsHelp.test.tsx @@ -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(, { + 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(, { width: 40 }); + // Wait for it to render + await waitFor(() => 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 8efcb646a1c..e18938fd629 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 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; + const { terminalWidth } = useUIState(); + const items = buildShortcutItems(); - 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)}` - )} - - ); - }); - })} - - ); - } + const isNarrow = isNarrowWidth(terminalWidth); 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 00000000000..431f98d2b1e --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ShortcutsHelp.test.tsx.snap @@ -0,0 +1,21 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ShortcutsHelp > renders correctly in narrow mode 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 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 3d9bacbb44a..92935617a7d 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 00000000000..780c0940128 --- /dev/null +++ b/packages/cli/src/ui/components/shared/SectionHeader.test.tsx @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../../test-utils/render.js'; +import { SectionHeader } from './SectionHeader.js'; +import { describe, it, expect } from 'vitest'; + +describe('', () => { + it('renders correctly with a standard title', () => { + const { lastFrame, unmount } = renderWithProviders( + , + { width: 40 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders correctly when title is truncated but still shows dashes', () => { + // Width 20, title "Very Long Header Title That Will Truncate" + // Prefix "── " is 3 chars. "Very Lon..." + // Line box minWidth 2 + marginLeft 1 = 3 chars. + const { lastFrame, unmount } = renderWithProviders( + , + { width: 20 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders correctly in a narrow container', () => { + const { lastFrame, unmount } = renderWithProviders( + , + { width: 25 }, + ); + + 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 83a698afc11..daa41379fb6 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 00000000000..0548dd8b8e1 --- /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 container 1`] = `"── Narrow Container ─────"`; + +exports[` > renders correctly when title is truncated but still shows dashes 1`] = `"── Very Long Hea… ──"`; + +exports[` > renders correctly with a standard title 1`] = `"── My Header ───────────────────────────"`;