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