Add shortcuts hint and panel for discoverability (#18035)

This commit is contained in:
Dmitry Lyalin
2026-02-06 11:33:39 -08:00
committed by GitHub
parent ec5836c4d6
commit 1f1cf756c8
25 changed files with 639 additions and 54 deletions
@@ -24,7 +24,7 @@ vi.mock('../contexts/VimModeContext.js', () => ({
})),
}));
import { ApprovalMode } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js';
import { StreamingState, ToolCallStatus } from '../types.js';
// Mock child components
vi.mock('./LoadingIndicator.js', () => ({
@@ -49,6 +49,14 @@ vi.mock('./ShellModeIndicator.js', () => ({
ShellModeIndicator: () => <Text>ShellModeIndicator</Text>,
}));
vi.mock('./ShortcutsHint.js', () => ({
ShortcutsHint: () => <Text>ShortcutsHint</Text>,
}));
vi.mock('./ShortcutsHelp.js', () => ({
ShortcutsHelp: () => <Text>ShortcutsHelp</Text>,
}));
vi.mock('./DetailedMessagesDisplay.js', () => ({
DetailedMessagesDisplay: () => <Text>DetailedMessagesDisplay</Text>,
}));
@@ -95,7 +103,8 @@ vi.mock('../contexts/OverflowContext.js', () => ({
// Create mock context providers
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
({
streamingState: null,
streamingState: StreamingState.Idle,
isConfigInitialized: true,
contextFileNames: [],
showApprovalModeIndicator: ApprovalMode.DEFAULT,
messageQueue: [],
@@ -116,6 +125,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
showEscapePrompt: false,
shortcutsHelpVisible: false,
ideContextState: null,
geminiMdFileCount: 0,
renderMarkdown: true,
@@ -268,6 +278,19 @@ describe('Composer', () => {
expect(output).toContain('LoadingIndicator');
});
it('keeps shortcuts hint visible while loading', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
elapsedTime: 1,
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).toContain('ShortcutsHint');
});
it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
@@ -284,7 +307,7 @@ describe('Composer', () => {
expect(output).not.toContain('Should not show');
});
it('suppresses thought when waiting for confirmation', () => {
it('does not render LoadingIndicator when waiting for confirmation', () => {
const uiState = createMockUIState({
streamingState: StreamingState.WaitingForConfirmation,
thought: {
@@ -296,8 +319,34 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('Should not show during confirmation');
expect(output).not.toContain('LoadingIndicator');
});
it('does not render LoadingIndicator when a tool confirmation is pending', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
callId: 'call-1',
name: 'edit',
description: 'edit file',
status: ToolCallStatus.Confirming,
resultDisplay: undefined,
confirmationDetails: undefined,
},
],
},
],
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('LoadingIndicator');
expect(output).not.toContain('esc to cancel');
});
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => {
@@ -444,7 +493,7 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ApprovalModeIndicator');
expect(lastFrame()).toMatch(/ApprovalModeIndic[\s\S]*ator/);
});
it('shows ShellModeIndicator when shell mode is active', () => {
@@ -454,7 +503,7 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ShellModeIndicator');
expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/);
});
it('shows RawMarkdownIndicator when renderMarkdown is false', () => {
+145 -37
View File
@@ -5,17 +5,20 @@
*/
import { useState } from 'react';
import { Box, useIsScreenReaderEnabled } from 'ink';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StatusDisplay } from './StatusDisplay.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { ShortcutsHint } from './ShortcutsHint.js';
import { ShortcutsHelp } from './ShortcutsHelp.js';
import { InputPrompt } from './InputPrompt.js';
import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { HorizontalLine } from './shared/HorizontalLine.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useUIState } from '../contexts/UIStateContext.js';
@@ -25,9 +28,10 @@ import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { ApprovalMode } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js';
import { StreamingState, ToolCallStatus } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js';
import { theme } from '../semantic-colors.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const config = useConfig();
@@ -46,6 +50,31 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
const hideContextSummary =
suggestionsVisible && suggestionsPosition === 'above';
const hasPendingToolConfirmation = (uiState.pendingHistoryItems ?? []).some(
(item) =>
item.type === 'tool_group' &&
item.tools.some((tool) => tool.status === ToolCallStatus.Confirming),
);
const hasPendingActionRequired =
hasPendingToolConfirmation ||
Boolean(uiState.commandConfirmationRequest) ||
Boolean(uiState.authConsentRequest) ||
(uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 ||
Boolean(uiState.loopDetectionConfirmationRequest) ||
Boolean(uiState.proQuotaRequest) ||
Boolean(uiState.validationRequest) ||
Boolean(uiState.customDialog);
const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
uiState.streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
const showApprovalIndicator =
showApprovalModeIndicator !== ApprovalMode.DEFAULT &&
!uiState.shellModeActive;
const showRawMarkdownIndicator = !uiState.renderMarkdown;
const showEscToCancelHint =
showLoadingIndicator &&
uiState.streamingState !== StreamingState.WaitingForConfirmation;
return (
<Box
@@ -54,23 +83,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexGrow={0}
flexShrink={0}
>
{(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && (
<LoadingIndicator
thought={
uiState.streamingState === StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.enableLoadingPhrases === false
? undefined
: uiState.thought
}
currentLoadingPhrase={
config.getAccessibility()?.enableLoadingPhrases === false
? undefined
: uiState.currentLoadingPhrase
}
elapsedTime={uiState.elapsedTime}
/>
)}
{(!uiState.slashCommands ||
!uiState.isConfigInitialized ||
uiState.isResuming) && (
@@ -83,25 +95,121 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
<TodoTray />
<Box
marginTop={1}
justifyContent={
settings.merged.ui.hideContextSummary ? 'flex-start' : 'space-between'
}
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box marginRight={1}>
<StatusDisplay hideContextSummary={hideContextSummary} />
</Box>
<Box paddingTop={isNarrow ? 1 : 0}>
{showApprovalModeIndicator !== ApprovalMode.DEFAULT &&
!uiState.shellModeActive && (
<ApprovalModeIndicator approvalMode={showApprovalModeIndicator} />
<Box marginTop={1} width="100%" flexDirection="column">
{showEscToCancelHint && (
<Box marginLeft={3}>
<Text color={theme.text.secondary}>esc to cancel</Text>
</Box>
)}
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
justifyContent={isNarrow ? 'flex-start' : 'space-between'}
>
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems="center"
flexGrow={1}
>
{showLoadingIndicator && (
<LoadingIndicator
inline
thought={
uiState.streamingState ===
StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.enableLoadingPhrases === false
? undefined
: uiState.thought
}
currentLoadingPhrase={
config.getAccessibility()?.enableLoadingPhrases === false
? undefined
: uiState.currentLoadingPhrase
}
elapsedTime={uiState.elapsedTime}
showCancelAndTimer={false}
/>
)}
{uiState.shellModeActive && <ShellModeIndicator />}
{!uiState.renderMarkdown && <RawMarkdownIndicator />}
</Box>
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
>
<ShortcutsHint />
</Box>
</Box>
{uiState.shortcutsHelpVisible && <ShortcutsHelp />}
<HorizontalLine width={uiState.terminalWidth} />
<Box
justifyContent={
settings.merged.ui.hideContextSummary
? 'flex-start'
: 'space-between'
}
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems="center"
flexGrow={1}
>
{!showLoadingIndicator && (
<Box
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
/>
)}
{uiState.shellModeActive && (
<Box
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
>
<ShellModeIndicator />
</Box>
)}
{showRawMarkdownIndicator && (
<Box
marginLeft={
(showApprovalIndicator || uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
marginTop={
(showApprovalIndicator || uiState.shellModeActive) &&
isNarrow
? 1
: 0
}
>
<RawMarkdownIndicator />
</Box>
)}
</Box>
)}
</Box>
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
>
{!showLoadingIndicator && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
</Box>
</Box>
</Box>
+39 -1
View File
@@ -151,7 +151,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const { merged: settings } = useSettings();
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
const { setEmbeddedShellFocused } = useUIActions();
const { setEmbeddedShellFocused, setShortcutsHelpVisible } = useUIActions();
const {
terminalWidth,
activePtyId,
@@ -159,6 +159,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
terminalBackgroundColor,
backgroundShells,
backgroundShellHeight,
shortcutsHelpVisible,
} = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false);
const escPressCount = useRef(0);
@@ -535,6 +536,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return false;
}
// Handle escape to close shortcuts panel first, before letting it bubble
// up for cancellation. This ensures pressing Escape once closes the panel,
// and pressing again cancels the operation.
if (shortcutsHelpVisible && key.name === 'escape') {
setShortcutsHelpVisible(false);
return true;
}
if (
key.name === 'escape' &&
(streamingState === StreamingState.Responding ||
@@ -572,6 +581,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
if (shortcutsHelpVisible) {
if (key.sequence === '?' && key.insertable) {
setShortcutsHelpVisible(false);
buffer.handleInput(key);
return true;
}
// Escape is handled earlier to ensure it closes the panel before
// potentially cancelling an operation
if (key.name === 'backspace' || key.sequence === '\b') {
setShortcutsHelpVisible(false);
return true;
}
if (key.insertable) {
setShortcutsHelpVisible(false);
}
}
if (
key.sequence === '?' &&
key.insertable &&
!shortcutsHelpVisible &&
buffer.text.length === 0
) {
setShortcutsHelpVisible(true);
return true;
}
if (vimHandleInput && vimHandleInput(key)) {
return true;
}
@@ -1044,6 +1080,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
commandSearchActive,
commandSearchCompletion,
kittyProtocol.enabled,
shortcutsHelpVisible,
setShortcutsHelpVisible,
tryLoadQueuedMessages,
setBannerVisible,
onSubmit,
@@ -57,9 +57,9 @@ describe('<LoadingIndicator />', () => {
elapsedTime: 5,
};
it('should not render when streamingState is Idle', () => {
it('should not render when streamingState is Idle and no loading phrase or thought', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
<LoadingIndicator elapsedTime={5} />,
StreamingState.Idle,
);
expect(lastFrame()).toBe('');
@@ -143,10 +143,10 @@ describe('<LoadingIndicator />', () => {
it('should transition correctly between states using rerender', () => {
const { lastFrame, rerender, unmount } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
<LoadingIndicator elapsedTime={5} />,
StreamingState.Idle,
);
expect(lastFrame()).toBe(''); // Initial: Idle
expect(lastFrame()).toBe(''); // Initial: Idle (no loading phrase)
// Transition to Responding
rerender(
@@ -180,10 +180,10 @@ describe('<LoadingIndicator />', () => {
// Transition back to Idle
rerender(
<StreamingContext.Provider value={StreamingState.Idle}>
<LoadingIndicator {...defaultProps} />
<LoadingIndicator elapsedTime={5} />
</StreamingContext.Provider>,
);
expect(lastFrame()).toBe('');
expect(lastFrame()).toBe(''); // Idle with no loading phrase
unmount();
});
@@ -19,21 +19,29 @@ import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
interface LoadingIndicatorProps {
currentLoadingPhrase?: string;
elapsedTime: number;
inline?: boolean;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
showCancelAndTimer?: boolean;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
currentLoadingPhrase,
elapsedTime,
inline = false,
rightContent,
thought,
showCancelAndTimer = true,
}) => {
const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
if (streamingState === StreamingState.Idle) {
if (
streamingState === StreamingState.Idle &&
!currentLoadingPhrase &&
!thought
) {
return null;
}
@@ -45,10 +53,38 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
: thought?.subject || currentLoadingPhrase;
const cancelAndTimerContent =
showCancelAndTimer &&
streamingState !== StreamingState.WaitingForConfirmation
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
: null;
if (inline) {
return (
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
}
/>
</Box>
{primaryText && (
<Text color={theme.text.accent} wrap="truncate-end">
{primaryText}
</Text>
)}
{cancelAndTimerContent && (
<>
<Box flexShrink={0} width={1} />
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
</>
)}
</Box>
);
}
return (
<Box paddingLeft={0} flexDirection="column">
{/* Main loading line */}
@@ -0,0 +1,232 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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';
type ShortcutItem = {
key: string;
description: string;
};
const buildShortcutRows = (): 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' },
],
];
};
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);
};
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;
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">
<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>
);
};
@@ -0,0 +1,19 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
export const ShortcutsHint: React.FC = () => {
const { shortcutsHelpVisible } = useUIState();
const highlightColor = shortcutsHelpVisible
? theme.text.accent
: theme.text.secondary;
return <Text color={highlightColor}> ? for shortcuts </Text>;
};
@@ -43,6 +43,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
warningMessage: null,
ctrlDPressedOnce: false,
showEscapePrompt: false,
shortcutsHelpVisible: false,
queueErrorMessage: null,
activeHooks: [],
ideContextState: null,
@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
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>;
};
@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import stringWidth from 'string-width';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
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>;
};