mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-09 00:16:57 -07:00
feat(ui): implement refreshed UX for Composer layout (#21212)
Co-authored-by: Keith Guerin <keithguerin@gmail.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
renderWithProviders,
|
||||
persistentStateMock,
|
||||
} from '../../test-utils/render.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
@@ -264,4 +265,23 @@ describe('<AppHeader />', () => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT render Tips when ui.hideTips is true', async () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
settings: {
|
||||
merged: {
|
||||
ui: { hideTips: true },
|
||||
},
|
||||
} as unknown as LoadedSettings,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).not.toContain('Tips');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,13 +17,6 @@ import {
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
// Mock VimModeContext hook
|
||||
vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
useVimMode: vi.fn(() => ({
|
||||
vimEnabled: false,
|
||||
vimMode: 'INSERT',
|
||||
})),
|
||||
}));
|
||||
import {
|
||||
ApprovalMode,
|
||||
tokenLimit,
|
||||
@@ -36,6 +29,21 @@ import type { LoadedSettings } from '../../config/settings.js';
|
||||
import type { SessionMetrics } from '../contexts/SessionContext.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
|
||||
// Mock VimModeContext hook
|
||||
vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
useVimMode: vi.fn(() => ({
|
||||
vimEnabled: false,
|
||||
vimMode: 'INSERT',
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(() => ({
|
||||
columns: 100,
|
||||
rows: 24,
|
||||
})),
|
||||
}));
|
||||
|
||||
const composerTestControls = vi.hoisted(() => ({
|
||||
suggestionsVisible: false,
|
||||
isAlternateBuffer: false,
|
||||
@@ -58,18 +66,9 @@ vi.mock('./LoadingIndicator.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('./StatusDisplay.js', () => ({
|
||||
StatusDisplay: () => <Text>StatusDisplay</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ToastDisplay.js', () => ({
|
||||
ToastDisplay: () => <Text>ToastDisplay</Text>,
|
||||
shouldShowToast: (uiState: UIState) =>
|
||||
uiState.ctrlCPressedOnce ||
|
||||
Boolean(uiState.transientMessage) ||
|
||||
uiState.ctrlDPressedOnce ||
|
||||
(uiState.showEscapePrompt &&
|
||||
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
|
||||
Boolean(uiState.queueErrorMessage),
|
||||
StatusDisplay: ({ hideContextSummary }: { hideContextSummary: boolean }) => (
|
||||
<Text>StatusDisplay{hideContextSummary ? ' (hidden summary)' : ''}</Text>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./ContextSummaryDisplay.js', () => ({
|
||||
@@ -81,17 +80,15 @@ vi.mock('./HookStatusDisplay.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('./ApprovalModeIndicator.js', () => ({
|
||||
ApprovalModeIndicator: () => <Text>ApprovalModeIndicator</Text>,
|
||||
ApprovalModeIndicator: ({ approvalMode }: { approvalMode: ApprovalMode }) => (
|
||||
<Text>ApprovalModeIndicator: {approvalMode}</Text>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./ShellModeIndicator.js', () => ({
|
||||
ShellModeIndicator: () => <Text>ShellModeIndicator</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ShortcutsHint.js', () => ({
|
||||
ShortcutsHint: () => <Text>ShortcutsHint</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ShortcutsHelp.js', () => ({
|
||||
ShortcutsHelp: () => <Text>ShortcutsHelp</Text>,
|
||||
}));
|
||||
@@ -174,6 +171,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
isFocused: true,
|
||||
thought: '',
|
||||
currentLoadingPhrase: '',
|
||||
currentTip: '',
|
||||
currentWittyPhrase: '',
|
||||
elapsedTime: 0,
|
||||
ctrlCPressedOnce: false,
|
||||
ctrlDPressedOnce: false,
|
||||
@@ -201,6 +200,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
activeHooks: [],
|
||||
isBackgroundShellVisible: false,
|
||||
embeddedShellFocused: false,
|
||||
showIsExpandableHint: false,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
@@ -247,7 +247,7 @@ const createMockConfig = (overrides = {}): Config =>
|
||||
|
||||
const renderComposer = async (
|
||||
uiState: UIState,
|
||||
settings = createMockSettings(),
|
||||
settings = createMockSettings({ ui: {} }),
|
||||
config = createMockConfig(),
|
||||
uiActions = createMockUIActions(),
|
||||
) => {
|
||||
@@ -256,7 +256,7 @@ const renderComposer = async (
|
||||
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<Composer />
|
||||
<Composer isFocused={true} />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
@@ -383,10 +383,12 @@ describe('Composer', () => {
|
||||
const { lastFrame } = await renderComposer(uiState, settings);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator: Thinking...');
|
||||
// In Refreshed UX, we don't force 'Thinking...' label in renderStatusNode
|
||||
// It uses the subject directly
|
||||
expect(output).toContain('LoadingIndicator: Thinking about code');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint while loading', async () => {
|
||||
it('shows shortcuts hint while loading', async () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: 1,
|
||||
@@ -397,7 +399,8 @@ describe('Composer', () => {
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
expect(output).not.toContain('ShortcutsHint');
|
||||
expect(output).toContain('press tab twice for more');
|
||||
expect(output).not.toContain('? for shortcuts');
|
||||
});
|
||||
|
||||
it('renders LoadingIndicator with thought when loadingPhrases is off', async () => {
|
||||
@@ -453,9 +456,8 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('LoadingIndicator');
|
||||
expect(output).not.toContain('esc to cancel');
|
||||
const output = lastFrame({ allowEmpty: true });
|
||||
expect(output).toBe('');
|
||||
});
|
||||
|
||||
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', async () => {
|
||||
@@ -558,8 +560,10 @@ describe('Composer', () => {
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('ToastDisplay');
|
||||
expect(output).not.toContain('ApprovalModeIndicator');
|
||||
expect(output).toContain('Press Ctrl+C again to exit.');
|
||||
// In Refreshed UX, Row 1 shows toast, and Row 2 shows ApprovalModeIndicator/StatusDisplay
|
||||
// They are no longer mutually exclusive.
|
||||
expect(output).toContain('ApprovalModeIndicator');
|
||||
expect(output).toContain('StatusDisplay');
|
||||
});
|
||||
|
||||
@@ -574,8 +578,8 @@ describe('Composer', () => {
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('ToastDisplay');
|
||||
expect(output).not.toContain('ApprovalModeIndicator');
|
||||
expect(output).toContain('Warning');
|
||||
expect(output).toContain('ApprovalModeIndicator');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -584,15 +588,17 @@ describe('Composer', () => {
|
||||
const uiState = createMockUIState({
|
||||
cleanUiDetailsVisible: false,
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
ui: { showShortcutsHint: false },
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
const { lastFrame } = await renderComposer(uiState, settings);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('ShortcutsHint');
|
||||
expect(output).not.toContain('press tab twice for more');
|
||||
expect(output).not.toContain('? for shortcuts');
|
||||
expect(output).toContain('InputPrompt');
|
||||
expect(output).not.toContain('Footer');
|
||||
expect(output).not.toContain('ApprovalModeIndicator');
|
||||
expect(output).not.toContain('ContextSummaryDisplay');
|
||||
});
|
||||
|
||||
it('renders InputPrompt when input is active', async () => {
|
||||
@@ -665,12 +671,15 @@ describe('Composer', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ApprovalMode.YOLO, 'YOLO'],
|
||||
[ApprovalMode.PLAN, 'plan'],
|
||||
[ApprovalMode.AUTO_EDIT, 'auto edit'],
|
||||
{ mode: ApprovalMode.YOLO, label: '● YOLO' },
|
||||
{ mode: ApprovalMode.PLAN, label: '● plan' },
|
||||
{
|
||||
mode: ApprovalMode.AUTO_EDIT,
|
||||
label: '● auto edit',
|
||||
},
|
||||
])(
|
||||
'shows minimal mode badge "%s" when clean UI details are hidden',
|
||||
async (mode, label) => {
|
||||
'shows minimal mode badge "$mode" when clean UI details are hidden',
|
||||
async ({ mode, label }) => {
|
||||
const uiState = createMockUIState({
|
||||
cleanUiDetailsVisible: false,
|
||||
showApprovalModeIndicator: mode,
|
||||
@@ -693,7 +702,8 @@ describe('Composer', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
expect(output).not.toContain('plan');
|
||||
expect(output).not.toContain('ShortcutsHint');
|
||||
expect(output).toContain('press tab twice for more');
|
||||
expect(output).not.toContain('? for shortcuts');
|
||||
});
|
||||
|
||||
it('hides minimal mode badge while action-required state is active', async () => {
|
||||
@@ -708,9 +718,7 @@ describe('Composer', () => {
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('plan');
|
||||
expect(output).not.toContain('ShortcutsHint');
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
});
|
||||
|
||||
it('shows Esc rewind prompt in minimal mode without showing full UI', async () => {
|
||||
@@ -722,7 +730,7 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('ToastDisplay');
|
||||
expect(output).toContain('Press Esc again to rewind.');
|
||||
expect(output).not.toContain('ContextSummaryDisplay');
|
||||
});
|
||||
|
||||
@@ -747,7 +755,14 @@ describe('Composer', () => {
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState, settings);
|
||||
expect(lastFrame()).toContain('%');
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
// StatusDisplay (which contains ContextUsageDisplay) should bleed through in minimal mode
|
||||
expect(lastFrame()).toContain('StatusDisplay');
|
||||
expect(lastFrame()).toContain('70% used');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -812,14 +827,20 @@ describe('Composer', () => {
|
||||
|
||||
describe('Shortcuts Hint', () => {
|
||||
it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => {
|
||||
const { lastFrame } = await renderComposer(
|
||||
createMockUIState({
|
||||
buffer: { text: '' } as unknown as TextBuffer,
|
||||
cleanUiDetailsVisible: false,
|
||||
}),
|
||||
);
|
||||
const uiState = createMockUIState({
|
||||
buffer: { text: '' } as unknown as TextBuffer,
|
||||
cleanUiDetailsVisible: false,
|
||||
});
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint');
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
expect(lastFrame({ allowEmpty: true })).toContain(
|
||||
'press tab twice for more',
|
||||
);
|
||||
});
|
||||
|
||||
it('hides shortcuts hint when text is typed in buffer', async () => {
|
||||
@@ -830,7 +851,8 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
expect(lastFrame()).not.toContain('press tab twice for more');
|
||||
expect(lastFrame()).not.toContain('? for shortcuts');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint when showShortcutsHint setting is false', async () => {
|
||||
@@ -843,7 +865,7 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState, settings);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
expect(lastFrame()).not.toContain('? for shortcuts');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint when a action is required (e.g. dialog is open)', async () => {
|
||||
@@ -856,9 +878,10 @@ describe('Composer', () => {
|
||||
),
|
||||
});
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
const { lastFrame, unmount } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps shortcuts hint visible when no action is required', async () => {
|
||||
@@ -868,7 +891,11 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ShortcutsHint');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('press tab twice for more');
|
||||
});
|
||||
|
||||
it('shows shortcuts hint when full UI details are visible', async () => {
|
||||
@@ -878,10 +905,15 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ShortcutsHint');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
// In Refreshed UX, shortcuts hint is in the top multipurpose status row
|
||||
expect(lastFrame()).toContain('? for shortcuts');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint while loading when full UI details are visible', async () => {
|
||||
it('shows shortcuts hint while loading when full UI details are visible', async () => {
|
||||
const uiState = createMockUIState({
|
||||
cleanUiDetailsVisible: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
@@ -889,10 +921,17 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
// In experimental layout, status row is visible during loading
|
||||
expect(lastFrame()).toContain('LoadingIndicator');
|
||||
expect(lastFrame()).toContain('? for shortcuts');
|
||||
expect(lastFrame()).not.toContain('press tab twice for more');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint while loading in minimal mode', async () => {
|
||||
it('shows shortcuts hint while loading in minimal mode', async () => {
|
||||
const uiState = createMockUIState({
|
||||
cleanUiDetailsVisible: false,
|
||||
streamingState: StreamingState.Responding,
|
||||
@@ -901,7 +940,14 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
// In experimental layout, status row is visible in clean mode while busy
|
||||
expect(lastFrame()).toContain('LoadingIndicator');
|
||||
expect(lastFrame()).toContain('press tab twice for more');
|
||||
expect(lastFrame()).not.toContain('? for shortcuts');
|
||||
});
|
||||
|
||||
it('shows shortcuts help in minimal mode when toggled on', async () => {
|
||||
@@ -926,7 +972,8 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
expect(lastFrame()).not.toContain('press tab twice for more');
|
||||
expect(lastFrame()).not.toContain('? for shortcuts');
|
||||
expect(lastFrame()).not.toContain('plan');
|
||||
});
|
||||
|
||||
@@ -954,7 +1001,12 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ShortcutsHint');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
});
|
||||
|
||||
// In Refreshed UX, shortcuts hint is in the top status row and doesn't collide with suggestions below
|
||||
expect(lastFrame()).toContain('press tab twice for more');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -982,24 +1034,22 @@ describe('Composer', () => {
|
||||
expect(lastFrame()).not.toContain('ShortcutsHelp');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides shortcuts help when action is required', async () => {
|
||||
const uiState = createMockUIState({
|
||||
shortcutsHelpVisible: true,
|
||||
customDialog: (
|
||||
<Box>
|
||||
<Text>Dialog content</Text>
|
||||
<Text>Test Dialog</Text>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = await renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHelp');
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Snapshots', () => {
|
||||
it('matches snapshot in idle state', async () => {
|
||||
const uiState = createMockUIState();
|
||||
|
||||
@@ -4,58 +4,63 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import {
|
||||
ApprovalMode,
|
||||
checkExhaustive,
|
||||
CoreToolCallStatus,
|
||||
isUserVisibleHook,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { isContextUsageHigh } from '../utils/contextUsage.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
|
||||
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
||||
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { StatusDisplay } from './StatusDisplay.js';
|
||||
import { HorizontalLine } from './shared/HorizontalLine.js';
|
||||
import { ToastDisplay, shouldShowToast } from './ToastDisplay.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 { ContextUsageDisplay } from './ContextUsageDisplay.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';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
|
||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
|
||||
import { TodoTray } from './messages/Todo.js';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
import { isContextUsageHigh } from '../utils/contextUsage.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const { vimEnabled, vimMode } = useVimMode();
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
const terminalWidth = uiState.terminalWidth;
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
|
||||
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const { showApprovalModeIndicator } = uiState;
|
||||
const showApprovalModeIndicator = uiState.showApprovalModeIndicator;
|
||||
const loadingPhrases = settings.merged.ui.loadingPhrases;
|
||||
const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all';
|
||||
const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
|
||||
|
||||
const showUiDetails = uiState.cleanUiDetailsVisible;
|
||||
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
|
||||
const hideContextSummary =
|
||||
@@ -84,6 +89,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
Boolean(uiState.quota.proQuotaRequest) ||
|
||||
Boolean(uiState.quota.validationRequest) ||
|
||||
Boolean(uiState.customDialog);
|
||||
|
||||
const isPassiveShortcutsHelpState =
|
||||
uiState.isInputActive &&
|
||||
uiState.streamingState === StreamingState.Idle &&
|
||||
@@ -105,16 +111,30 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
uiState.shortcutsHelpVisible &&
|
||||
uiState.streamingState === StreamingState.Idle &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
/**
|
||||
* Use the setting if provided, otherwise default to true for the new UX.
|
||||
* This allows tests to override the collapse behavior.
|
||||
*/
|
||||
const shouldCollapseDuringApproval =
|
||||
settings.merged.ui.collapseDrawerDuringApproval !== false;
|
||||
|
||||
if (hasPendingActionRequired && shouldCollapseDuringApproval) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasToast = shouldShowToast(uiState);
|
||||
const showLoadingIndicator =
|
||||
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
|
||||
uiState.streamingState === StreamingState.Responding &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
const hideUiDetailsForSuggestions =
|
||||
suggestionsVisible && suggestionsPosition === 'above';
|
||||
const showApprovalIndicator =
|
||||
!uiState.shellModeActive && !hideUiDetailsForSuggestions;
|
||||
const showRawMarkdownIndicator = !uiState.renderMarkdown;
|
||||
|
||||
let modeBleedThrough: { text: string; color: string } | null = null;
|
||||
switch (showApprovalModeIndicator) {
|
||||
case ApprovalMode.YOLO:
|
||||
@@ -137,59 +157,359 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
|
||||
const hideMinimalModeHintWhileBusy =
|
||||
!showUiDetails && (showLoadingIndicator || hasPendingActionRequired);
|
||||
const minimalModeBleedThrough = hideMinimalModeHintWhileBusy
|
||||
? null
|
||||
: modeBleedThrough;
|
||||
const hasMinimalStatusBleedThrough = shouldShowToast(uiState);
|
||||
|
||||
const showMinimalContextBleedThrough =
|
||||
!settings.merged.ui.footer.hideContextPercentage &&
|
||||
isContextUsageHigh(
|
||||
uiState.sessionStats.lastPromptTokenCount,
|
||||
typeof uiState.currentModel === 'string'
|
||||
? uiState.currentModel
|
||||
: undefined,
|
||||
);
|
||||
const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions;
|
||||
const isModelIdle = uiState.streamingState === StreamingState.Idle;
|
||||
const isBufferEmpty = uiState.buffer.text.length === 0;
|
||||
const canShowShortcutsHint =
|
||||
isModelIdle && isBufferEmpty && !hasPendingActionRequired;
|
||||
const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] =
|
||||
useState(canShowShortcutsHint);
|
||||
// Universal Content Objects
|
||||
const modeContentObj = hideMinimalModeHintWhileBusy ? null : modeBleedThrough;
|
||||
|
||||
useEffect(() => {
|
||||
if (!canShowShortcutsHint) {
|
||||
setShowShortcutsHintDebounced(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setShowShortcutsHintDebounced(true);
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [canShowShortcutsHint]);
|
||||
const allHooks = uiState.activeHooks;
|
||||
const hasAnyHooks = allHooks.length > 0;
|
||||
const userVisibleHooks = allHooks.filter((h) => isUserVisibleHook(h.source));
|
||||
const hasUserVisibleHooks = userVisibleHooks.length > 0;
|
||||
|
||||
const shouldReserveSpaceForShortcutsHint =
|
||||
settings.merged.ui.showShortcutsHint &&
|
||||
!hideShortcutsHintForSuggestions &&
|
||||
!hideUiDetailsForSuggestions &&
|
||||
!hasPendingActionRequired;
|
||||
const showShortcutsHint =
|
||||
shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced;
|
||||
const showMinimalModeBleedThrough =
|
||||
!hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough);
|
||||
const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator;
|
||||
const showMinimalBleedThroughRow =
|
||||
!showUiDetails &&
|
||||
(showMinimalModeBleedThrough ||
|
||||
hasMinimalStatusBleedThrough ||
|
||||
showMinimalContextBleedThrough);
|
||||
const showMinimalMetaRow =
|
||||
!showUiDetails &&
|
||||
(showMinimalInlineLoading ||
|
||||
showMinimalBleedThroughRow ||
|
||||
shouldReserveSpaceForShortcutsHint);
|
||||
|
||||
const isInteractiveShellWaiting = uiState.currentLoadingPhrase?.includes(
|
||||
INTERACTIVE_SHELL_WAITING_PHRASE,
|
||||
);
|
||||
|
||||
/**
|
||||
* Calculate the estimated length of the status message to avoid collisions
|
||||
* with the tips area.
|
||||
*/
|
||||
let estimatedStatusLength = 0;
|
||||
if (hasAnyHooks) {
|
||||
if (hasUserVisibleHooks) {
|
||||
const hookLabel =
|
||||
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const hookNames = userVisibleHooks
|
||||
.map(
|
||||
(h) =>
|
||||
h.name +
|
||||
(h.index && h.total && h.total > 1
|
||||
? ` (${h.index}/${h.total})`
|
||||
: ''),
|
||||
)
|
||||
.join(', ');
|
||||
estimatedStatusLength = hookLabel.length + hookNames.length + 10;
|
||||
} else {
|
||||
estimatedStatusLength = GENERIC_WORKING_LABEL.length + 10;
|
||||
}
|
||||
} else if (showLoadingIndicator) {
|
||||
const thoughtText = uiState.thought?.subject || GENERIC_WORKING_LABEL;
|
||||
const inlineWittyLength =
|
||||
showWit && uiState.currentWittyPhrase
|
||||
? uiState.currentWittyPhrase.length + 1
|
||||
: 0;
|
||||
estimatedStatusLength = thoughtText.length + 25 + inlineWittyLength;
|
||||
} else if (hasPendingActionRequired) {
|
||||
estimatedStatusLength = 20;
|
||||
} else if (hasToast) {
|
||||
estimatedStatusLength = 40;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the ambient text (tip) to display.
|
||||
*/
|
||||
const tipContentStr = (() => {
|
||||
// 1. Proactive Tip (Priority)
|
||||
if (
|
||||
showTips &&
|
||||
uiState.currentTip &&
|
||||
!(
|
||||
isInteractiveShellWaiting &&
|
||||
uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE
|
||||
)
|
||||
) {
|
||||
if (
|
||||
estimatedStatusLength + uiState.currentTip.length + 10 <=
|
||||
terminalWidth
|
||||
) {
|
||||
return uiState.currentTip;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Shortcut Hint (Fallback)
|
||||
if (
|
||||
settings.merged.ui.showShortcutsHint &&
|
||||
!hideUiDetailsForSuggestions &&
|
||||
!hasPendingActionRequired &&
|
||||
uiState.buffer.text.length === 0
|
||||
) {
|
||||
return showUiDetails ? '? for shortcuts' : 'press tab twice for more';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
const tipLength = tipContentStr?.length || 0;
|
||||
const willCollideTip = estimatedStatusLength + tipLength + 5 > terminalWidth;
|
||||
|
||||
const showTipLine =
|
||||
!hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow;
|
||||
|
||||
// Mini Mode VIP Flags (Pure Content Triggers)
|
||||
const miniMode_ShowApprovalMode =
|
||||
Boolean(modeContentObj) && !hideUiDetailsForSuggestions;
|
||||
const miniMode_ShowToast = hasToast;
|
||||
const miniMode_ShowShortcuts = shouldReserveSpaceForShortcutsHint;
|
||||
const miniMode_ShowStatus = showLoadingIndicator || hasAnyHooks;
|
||||
const miniMode_ShowTip = showTipLine;
|
||||
const miniMode_ShowContext = isContextUsageHigh(
|
||||
uiState.sessionStats.lastPromptTokenCount,
|
||||
uiState.currentModel,
|
||||
settings.merged.model?.compressionThreshold,
|
||||
);
|
||||
|
||||
// Composite Mini Mode Triggers
|
||||
const showRow1_MiniMode =
|
||||
miniMode_ShowToast ||
|
||||
miniMode_ShowStatus ||
|
||||
miniMode_ShowShortcuts ||
|
||||
miniMode_ShowTip;
|
||||
|
||||
const showRow2_MiniMode = miniMode_ShowApprovalMode || miniMode_ShowContext;
|
||||
|
||||
// Final Display Rules (Stable Footer Architecture)
|
||||
const showRow1 = showUiDetails || showRow1_MiniMode;
|
||||
const showRow2 = showUiDetails || showRow2_MiniMode;
|
||||
|
||||
const showMinimalBleedThroughRow = !showUiDetails && showRow2_MiniMode;
|
||||
|
||||
const renderTipNode = () => {
|
||||
if (!tipContentStr) return null;
|
||||
|
||||
const isShortcutHint =
|
||||
tipContentStr === '? for shortcuts' ||
|
||||
tipContentStr === 'press tab twice for more';
|
||||
const color =
|
||||
isShortcutHint && uiState.shortcutsHelpVisible
|
||||
? theme.text.accent
|
||||
: theme.text.secondary;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" justifyContent="flex-end">
|
||||
<Text
|
||||
color={color}
|
||||
wrap="truncate-end"
|
||||
italic={
|
||||
!isShortcutHint && tipContentStr === uiState.currentWittyPhrase
|
||||
}
|
||||
>
|
||||
{tipContentStr === uiState.currentTip
|
||||
? `Tip: ${tipContentStr}`
|
||||
: tipContentStr}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatusNode = () => {
|
||||
const allHooks = uiState.activeHooks;
|
||||
if (allHooks.length === 0 && !showLoadingIndicator) return null;
|
||||
|
||||
if (allHooks.length > 0) {
|
||||
const userVisibleHooks = allHooks.filter((h) =>
|
||||
isUserVisibleHook(h.source),
|
||||
);
|
||||
|
||||
let hookText = GENERIC_WORKING_LABEL;
|
||||
if (userVisibleHooks.length > 0) {
|
||||
const label =
|
||||
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const displayNames = userVisibleHooks.map((h) => {
|
||||
let name = h.name;
|
||||
if (h.index && h.total && h.total > 1) {
|
||||
name += ` (${h.index}/${h.total})`;
|
||||
}
|
||||
return name;
|
||||
});
|
||||
hookText = `${label}: ${displayNames.join(', ')}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
showTips={showTips}
|
||||
showWit={showWit}
|
||||
errorVerbosity={settings.merged.ui.errorVerbosity}
|
||||
currentLoadingPhrase={hookText}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
forceRealStatusOnly={false}
|
||||
wittyPhrase={uiState.currentWittyPhrase}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
showTips={showTips}
|
||||
showWit={showWit}
|
||||
errorVerbosity={settings.merged.ui.errorVerbosity}
|
||||
thought={uiState.thought}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
forceRealStatusOnly={false}
|
||||
wittyPhrase={uiState.currentWittyPhrase}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const statusNode = renderStatusNode();
|
||||
|
||||
/**
|
||||
* Renders the minimal metadata row content shown when UI details are hidden.
|
||||
*/
|
||||
const renderMinimalMetaRowContent = () => (
|
||||
<Box flexDirection="row" columnGap={1}>
|
||||
{renderStatusNode()}
|
||||
{showMinimalBleedThroughRow && (
|
||||
<Box>
|
||||
{miniMode_ShowApprovalMode && modeContentObj && (
|
||||
<Text color={modeContentObj.color}>● {modeContentObj.text}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderStatusRow = () => {
|
||||
// Mini Mode Height Reservation (The "Anti-Jitter" line)
|
||||
if (!showUiDetails && !showRow1_MiniMode && !showRow2_MiniMode) {
|
||||
return <Box height={1} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Row 1: multipurpose status (thinking, hooks, wit, tips) */}
|
||||
{showRow1 && (
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
minHeight={1}
|
||||
>
|
||||
<Box flexDirection="row" flexGrow={1} flexShrink={1}>
|
||||
{!showUiDetails && showRow1_MiniMode ? (
|
||||
renderMinimalMetaRowContent()
|
||||
) : isInteractiveShellWaiting ? (
|
||||
<Box width="100%" marginLeft={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
! Shell awaiting input (Tab to focus)
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
flexGrow={1}
|
||||
flexShrink={0}
|
||||
marginLeft={1}
|
||||
>
|
||||
{statusNode}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexShrink={0} marginLeft={2} marginRight={isNarrow ? 0 : 1}>
|
||||
{!isNarrow && showTipLine && renderTipNode()}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Internal Separator Line */}
|
||||
{showRow1 &&
|
||||
showRow2 &&
|
||||
(showUiDetails || (showRow1_MiniMode && showRow2_MiniMode)) && (
|
||||
<Box width="100%">
|
||||
<HorizontalLine dim />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Row 2: Mode and Context Summary */}
|
||||
{showRow2 && (
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box flexDirection="row" alignItems="center" marginLeft={1}>
|
||||
{showUiDetails ? (
|
||||
<>
|
||||
{showApprovalIndicator && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
allowPlanMode={uiState.allowPlanMode}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
miniMode_ShowApprovalMode &&
|
||||
modeContentObj && (
|
||||
<Text color={modeContentObj.color}>
|
||||
● {modeContentObj.text}
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
marginLeft={isNarrow ? 1 : 0}
|
||||
>
|
||||
{(showUiDetails || miniMode_ShowContext) && (
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
)}
|
||||
{miniMode_ShowContext && !showUiDetails && (
|
||||
<Box marginLeft={1}>
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
|
||||
model={
|
||||
typeof uiState.currentModel === 'string'
|
||||
? uiState.currentModel
|
||||
: undefined
|
||||
}
|
||||
terminalWidth={uiState.terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -212,212 +532,16 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
|
||||
{showUiDetails && <TodoTray />}
|
||||
|
||||
<Box width="100%" flexDirection="column">
|
||||
<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={isNarrow ? 'flex-start' : 'center'}
|
||||
flexGrow={1}
|
||||
>
|
||||
{showUiDetails && showLoadingIndicator && (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
thought={
|
||||
uiState.streamingState ===
|
||||
StreamingState.WaitingForConfirmation
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
settings.merged.ui.loadingPhrases === 'off'
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
thoughtLabel={
|
||||
inlineThinkingMode === 'full' ? 'Thinking...' : undefined
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
minHeight={
|
||||
showUiDetails && shouldReserveSpaceForShortcutsHint ? 1 : 0
|
||||
}
|
||||
>
|
||||
{showUiDetails && showShortcutsHint && <ShortcutsHint />}
|
||||
</Box>
|
||||
</Box>
|
||||
{showMinimalMetaRow && (
|
||||
<Box
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
flexGrow={1}
|
||||
>
|
||||
{showMinimalInlineLoading && (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
thought={
|
||||
uiState.streamingState ===
|
||||
StreamingState.WaitingForConfirmation
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
settings.merged.ui.loadingPhrases === 'off'
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
thoughtLabel={
|
||||
inlineThinkingMode === 'full' ? 'Thinking...' : undefined
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
/>
|
||||
)}
|
||||
{showMinimalModeBleedThrough && minimalModeBleedThrough && (
|
||||
<Text color={minimalModeBleedThrough.color}>
|
||||
● {minimalModeBleedThrough.text}
|
||||
</Text>
|
||||
)}
|
||||
{hasMinimalStatusBleedThrough && (
|
||||
<Box
|
||||
marginLeft={
|
||||
showMinimalInlineLoading || showMinimalModeBleedThrough
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<ToastDisplay />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{(showMinimalContextBleedThrough ||
|
||||
shouldReserveSpaceForShortcutsHint) && (
|
||||
<Box
|
||||
marginTop={isNarrow && showMinimalBleedThroughRow ? 1 : 0}
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
minHeight={1}
|
||||
>
|
||||
{showMinimalContextBleedThrough && (
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
|
||||
model={uiState.currentModel}
|
||||
terminalWidth={uiState.terminalWidth}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
marginLeft={
|
||||
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
|
||||
}
|
||||
marginTop={showMinimalContextBleedThrough && isNarrow ? 1 : 0}
|
||||
>
|
||||
{showShortcutsHint && <ShortcutsHint />}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{showShortcutsHelp && <ShortcutsHelp />}
|
||||
{showUiDetails && <HorizontalLine />}
|
||||
{showUiDetails && (
|
||||
<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}
|
||||
>
|
||||
{hasToast ? (
|
||||
<ToastDisplay />
|
||||
) : (
|
||||
<Box
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{showApprovalIndicator && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
allowPlanMode={uiState.allowPlanMode}
|
||||
/>
|
||||
)}
|
||||
{!showLoadingIndicator && (
|
||||
<>
|
||||
{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>
|
||||
{showShortcutsHelp && <ShortcutsHelp />}
|
||||
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
{!showLoadingIndicator && (
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{(showUiDetails || miniMode_ShowToast) && (
|
||||
<Box minHeight={1} marginLeft={isNarrow ? 0 : 1}>
|
||||
<ToastDisplay />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box width="100%" flexDirection="column">
|
||||
{renderStatusRow()}
|
||||
</Box>
|
||||
|
||||
{showUiDetails && uiState.showErrorDetails && (
|
||||
|
||||
@@ -16,7 +16,7 @@ import { GeminiSpinner } from './GeminiSpinner.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export const ConfigInitDisplay = ({
|
||||
message: initialMessage = 'Initializing...',
|
||||
message: initialMessage = 'Working...',
|
||||
}: {
|
||||
message?: string;
|
||||
}) => {
|
||||
@@ -45,14 +45,14 @@ export const ConfigInitDisplay = ({
|
||||
const suffix = remaining > 0 ? `, +${remaining} more` : '';
|
||||
const mcpMessage = `Connecting to MCP servers... (${connected}/${clients.size}) - Waiting for: ${displayedServers}${suffix}`;
|
||||
setMessage(
|
||||
initialMessage && initialMessage !== 'Initializing...'
|
||||
initialMessage && initialMessage !== 'Working...'
|
||||
? `${initialMessage} (${mcpMessage})`
|
||||
: mcpMessage,
|
||||
);
|
||||
} else {
|
||||
const mcpMessage = `Connecting to MCP servers... (${connected}/${clients.size})`;
|
||||
setMessage(
|
||||
initialMessage && initialMessage !== 'Initializing...'
|
||||
initialMessage && initialMessage !== 'Working...'
|
||||
? `${initialMessage} (${mcpMessage})`
|
||||
: mcpMessage,
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { type ReactNode } from 'react';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
|
||||
type ConsentPromptProps = {
|
||||
// If a simple string is given, it will render using markdown by default.
|
||||
@@ -37,7 +38,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
|
||||
) : (
|
||||
prompt
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<RadioButtonSelect
|
||||
items={[
|
||||
{ label: 'Yes', value: true, key: 'Yes' },
|
||||
@@ -45,6 +46,10 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
|
||||
]}
|
||||
onSelect={onConfirm}
|
||||
/>
|
||||
<DialogFooter
|
||||
primaryAction="Enter to select"
|
||||
navigationActions="↑/↓ to navigate"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -77,32 +77,6 @@ describe('<ContextSummaryDisplay />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should switch layout at the 80-column breakpoint', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
geminiMdFileCount: 1,
|
||||
contextFileNames: ['GEMINI.md'],
|
||||
mcpServers: { 'test-server': { command: 'test' } },
|
||||
ideContext: {
|
||||
workspaceState: {
|
||||
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// At 80 columns, should be on one line
|
||||
const { lastFrame: wideFrame, unmount: unmountWide } =
|
||||
await renderWithWidth(80, props);
|
||||
expect(wideFrame().trim().includes('\n')).toBe(false);
|
||||
unmountWide();
|
||||
|
||||
// At 79 columns, should be on multiple lines
|
||||
const { lastFrame: narrowFrame, unmount: unmountNarrow } =
|
||||
await renderWithWidth(79, props);
|
||||
expect(narrowFrame().trim().includes('\n')).toBe(true);
|
||||
expect(narrowFrame().trim().split('\n').length).toBe(4);
|
||||
unmountNarrow();
|
||||
});
|
||||
it('should not render empty parts', async () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
|
||||
@@ -8,8 +8,6 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { type IdeContext, type MCPServerConfig } from '@google/gemini-cli-core';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
|
||||
interface ContextSummaryDisplayProps {
|
||||
geminiMdFileCount: number;
|
||||
@@ -30,8 +28,6 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
skillCount,
|
||||
backgroundProcessCount = 0,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const mcpServerCount = Object.keys(mcpServers || {}).length;
|
||||
const blockedMcpServerCount = blockedMcpServers?.length || 0;
|
||||
const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
|
||||
@@ -44,7 +40,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
skillCount === 0 &&
|
||||
backgroundProcessCount === 0
|
||||
) {
|
||||
return <Text> </Text>; // Render an empty space to reserve height
|
||||
return null;
|
||||
}
|
||||
|
||||
const openFilesText = (() => {
|
||||
@@ -113,21 +109,14 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
backgroundText,
|
||||
].filter(Boolean);
|
||||
|
||||
if (isNarrow) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{summaryParts.map((part, index) => (
|
||||
<Text key={index} color={theme.text.secondary}>
|
||||
- {part}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text color={theme.text.secondary}>{summaryParts.join(' | ')}</Text>
|
||||
<Box paddingX={1} flexDirection="row" flexWrap="wrap">
|
||||
{summaryParts.map((part, index) => (
|
||||
<Box key={index} flexDirection="row">
|
||||
{index > 0 && <Text color={theme.text.secondary}>{' · '}</Text>}
|
||||
<Text color={theme.text.secondary}>{part}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,14 +23,28 @@ interface GeminiRespondingSpinnerProps {
|
||||
*/
|
||||
nonRespondingDisplay?: string;
|
||||
spinnerType?: SpinnerName;
|
||||
/**
|
||||
* If true, we prioritize showing the nonRespondingDisplay (hook icon)
|
||||
* even if the state is Responding.
|
||||
*/
|
||||
isHookActive?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const GeminiRespondingSpinner: React.FC<
|
||||
GeminiRespondingSpinnerProps
|
||||
> = ({ nonRespondingDisplay, spinnerType = 'dots' }) => {
|
||||
> = ({
|
||||
nonRespondingDisplay,
|
||||
spinnerType = 'dots',
|
||||
isHookActive = false,
|
||||
color,
|
||||
}) => {
|
||||
const streamingState = useStreamingContext();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
|
||||
// If a hook is active, we want to show the hook icon (nonRespondingDisplay)
|
||||
// to be consistent, instead of the rainbow spinner which means "Gemini is talking".
|
||||
if (streamingState === StreamingState.Responding && !isHookActive) {
|
||||
return (
|
||||
<GeminiSpinner
|
||||
spinnerType={spinnerType}
|
||||
@@ -43,7 +57,7 @@ export const GeminiRespondingSpinner: React.FC<
|
||||
return isScreenReaderEnabled ? (
|
||||
<Text>{SCREEN_READER_LOADING}</Text>
|
||||
) : (
|
||||
<Text color={theme.text.primary}>{nonRespondingDisplay}</Text>
|
||||
<Text color={color ?? theme.text.primary}>{nonRespondingDisplay}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@ describe('<HookStatusDisplay />', () => {
|
||||
const props = {
|
||||
activeHooks: [{ name: 'test-hook', eventName: 'BeforeAgent' }],
|
||||
};
|
||||
const { lastFrame, unmount } = await render(
|
||||
const { lastFrame, unmount, waitUntilReady } = await render(
|
||||
<HookStatusDisplay {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -32,9 +33,10 @@ describe('<HookStatusDisplay />', () => {
|
||||
{ name: 'h2', eventName: 'BeforeAgent' },
|
||||
],
|
||||
};
|
||||
const { lastFrame, unmount } = await render(
|
||||
const { lastFrame, unmount, waitUntilReady } = await render(
|
||||
<HookStatusDisplay {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -45,19 +47,47 @@ describe('<HookStatusDisplay />', () => {
|
||||
{ name: 'step', eventName: 'BeforeAgent', index: 1, total: 3 },
|
||||
],
|
||||
};
|
||||
const { lastFrame, unmount } = await render(
|
||||
const { lastFrame, unmount, waitUntilReady } = await render(
|
||||
<HookStatusDisplay {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should return empty string if no active hooks', async () => {
|
||||
const props = { activeHooks: [] };
|
||||
const { lastFrame, unmount } = await render(
|
||||
const { lastFrame, unmount, waitUntilReady } = await render(
|
||||
<HookStatusDisplay {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show generic message when only system hooks are active', async () => {
|
||||
const props = {
|
||||
activeHooks: [
|
||||
{ name: 'sys-hook', eventName: 'BeforeAgent', source: 'system' },
|
||||
],
|
||||
};
|
||||
const { lastFrame, unmount, waitUntilReady } = await render(
|
||||
<HookStatusDisplay {...props} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Working...');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('matches SVG snapshot for single hook', async () => {
|
||||
const props = {
|
||||
activeHooks: [
|
||||
{ name: 'test-hook', eventName: 'BeforeAgent', source: 'user' },
|
||||
],
|
||||
};
|
||||
const result = await render(<HookStatusDisplay {...props} />);
|
||||
await result.waitUntilReady();
|
||||
await expect(result).toMatchSvgSnapshot();
|
||||
result.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { type ActiveHook } from '../types.js';
|
||||
import { isUserVisibleHook } from '@google/gemini-cli-core';
|
||||
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
interface HookStatusDisplayProps {
|
||||
activeHooks: ActiveHook[];
|
||||
@@ -20,20 +22,30 @@ export const HookStatusDisplay: React.FC<HookStatusDisplayProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = activeHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const displayNames = activeHooks.map((hook) => {
|
||||
let name = hook.name;
|
||||
if (hook.index && hook.total && hook.total > 1) {
|
||||
name += ` (${hook.index}/${hook.total})`;
|
||||
}
|
||||
return name;
|
||||
});
|
||||
const userHooks = activeHooks.filter((h) => isUserVisibleHook(h.source));
|
||||
|
||||
const text = `${label}: ${displayNames.join(', ')}`;
|
||||
if (userHooks.length > 0) {
|
||||
const label = userHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
|
||||
const displayNames = userHooks.map((hook) => {
|
||||
let name = hook.name;
|
||||
if (hook.index && hook.total && hook.total > 1) {
|
||||
name += ` (${hook.index}/${hook.total})`;
|
||||
}
|
||||
return name;
|
||||
});
|
||||
|
||||
const text = `${label}: ${displayNames.join(', ')}`;
|
||||
return (
|
||||
<Text color={theme.text.secondary} italic={true}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// If only system/extension hooks are running, show a generic message.
|
||||
return (
|
||||
<Text color={theme.status.warning} wrap="truncate">
|
||||
{text}
|
||||
<Text color={theme.text.secondary} italic={true}>
|
||||
{GENERIC_WORKING_LABEL}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Text } from 'ink';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { StreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { vi } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
|
||||
// Mock GeminiRespondingSpinner
|
||||
@@ -50,26 +50,28 @@ const renderWithContext = async (
|
||||
|
||||
describe('<LoadingIndicator />', () => {
|
||||
const defaultProps = {
|
||||
currentLoadingPhrase: 'Loading...',
|
||||
currentLoadingPhrase: 'Thinking...',
|
||||
elapsedTime: 5,
|
||||
};
|
||||
|
||||
it('should render blank when streamingState is Idle and no loading phrase or thought', async () => {
|
||||
const { lastFrame } = await renderWithContext(
|
||||
const { lastFrame, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator elapsedTime={5} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('should render spinner, phrase, and time when streamingState is Responding', async () => {
|
||||
const { lastFrame } = await renderWithContext(
|
||||
const { lastFrame, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MockRespondingSpinner');
|
||||
expect(output).toContain('Loading...');
|
||||
expect(output).toContain('Thinking...');
|
||||
expect(output).toContain('(esc to cancel, 5s)');
|
||||
});
|
||||
|
||||
@@ -78,10 +80,11 @@ describe('<LoadingIndicator />', () => {
|
||||
currentLoadingPhrase: 'Confirm action',
|
||||
elapsedTime: 10,
|
||||
};
|
||||
const { lastFrame } = await renderWithContext(
|
||||
const { lastFrame, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.WaitingForConfirmation,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
|
||||
expect(output).toContain('Confirm action');
|
||||
@@ -94,46 +97,50 @@ describe('<LoadingIndicator />', () => {
|
||||
currentLoadingPhrase: 'Processing data...',
|
||||
elapsedTime: 3,
|
||||
};
|
||||
const { lastFrame, unmount } = await renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Processing data...');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display the elapsedTime correctly when Responding', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Working...',
|
||||
currentLoadingPhrase: 'Thinking...',
|
||||
elapsedTime: 60,
|
||||
};
|
||||
const { lastFrame, unmount } = await renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('(esc to cancel, 1m)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display the elapsedTime correctly in human-readable format', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Working...',
|
||||
currentLoadingPhrase: 'Thinking...',
|
||||
elapsedTime: 125,
|
||||
};
|
||||
const { lastFrame, unmount } = await renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render rightContent when provided', async () => {
|
||||
const rightContent = <Text>Extra Info</Text>;
|
||||
const { lastFrame, unmount } = await renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} rightContent={rightContent} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Extra Info');
|
||||
unmount();
|
||||
});
|
||||
@@ -174,6 +181,7 @@ describe('<LoadingIndicator />', () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
||||
<TestWrapper />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })?.trim()).toBe(''); // Initial: Idle (no loading phrase)
|
||||
|
||||
// Transition to Responding
|
||||
@@ -221,15 +229,16 @@ describe('<LoadingIndicator />', () => {
|
||||
it('should display fallback phrase if thought is empty', async () => {
|
||||
const props = {
|
||||
thought: null,
|
||||
currentLoadingPhrase: 'Loading...',
|
||||
currentLoadingPhrase: 'Thinking...',
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame, unmount } = await renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Loading...');
|
||||
expect(output).toContain('Thinking...');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -241,10 +250,11 @@ describe('<LoadingIndicator />', () => {
|
||||
},
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame, unmount } = await renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
if (output) {
|
||||
@@ -256,7 +266,7 @@ describe('<LoadingIndicator />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should prepend "Thinking... " if the subject does not start with "Thinking"', async () => {
|
||||
it('should NOT prepend "Thinking... " even if the subject does not start with "Thinking"', async () => {
|
||||
const props = {
|
||||
thought: {
|
||||
subject: 'Planning the response...',
|
||||
@@ -264,12 +274,14 @@ describe('<LoadingIndicator />', () => {
|
||||
},
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame, unmount } = await renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Thinking... Planning the response...');
|
||||
expect(output).toContain('Planning the response...');
|
||||
expect(output).not.toContain('Thinking... ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -282,31 +294,32 @@ describe('<LoadingIndicator />', () => {
|
||||
currentLoadingPhrase: 'This should not be displayed',
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame, unmount } = await renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Thinking... ');
|
||||
expect(output).toContain('This should be displayed');
|
||||
expect(output).not.toContain('This should not be displayed');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not display thought indicator for non-thought loading phrases', async () => {
|
||||
const { lastFrame, unmount } = await renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator
|
||||
currentLoadingPhrase="some random tip..."
|
||||
elapsedTime={3}
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('Thinking... ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should truncate long primary text instead of wrapping', async () => {
|
||||
const { lastFrame, unmount } = await renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator
|
||||
{...defaultProps}
|
||||
currentLoadingPhrase={
|
||||
@@ -316,14 +329,14 @@ describe('<LoadingIndicator />', () => {
|
||||
StreamingState.Responding,
|
||||
80,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('responsive layout', () => {
|
||||
it('should render on a single line on a wide terminal', async () => {
|
||||
const { lastFrame, unmount } = await renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator
|
||||
{...defaultProps}
|
||||
rightContent={<Text>Right</Text>}
|
||||
@@ -331,17 +344,18 @@ describe('<LoadingIndicator />', () => {
|
||||
StreamingState.Responding,
|
||||
120,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
// Check for single line output
|
||||
expect(output?.trim().includes('\n')).toBe(false);
|
||||
expect(output).toContain('Loading...');
|
||||
expect(output).toContain('Thinking...');
|
||||
expect(output).toContain('(esc to cancel, 5s)');
|
||||
expect(output).toContain('Right');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render on multiple lines on a narrow terminal', async () => {
|
||||
const { lastFrame, unmount } = await renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator
|
||||
{...defaultProps}
|
||||
rightContent={<Text>Right</Text>}
|
||||
@@ -349,6 +363,7 @@ describe('<LoadingIndicator />', () => {
|
||||
StreamingState.Responding,
|
||||
79,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
const lines = output?.trim().split('\n');
|
||||
// Expecting 3 lines:
|
||||
@@ -357,7 +372,7 @@ describe('<LoadingIndicator />', () => {
|
||||
// 3. Right Content
|
||||
expect(lines).toHaveLength(3);
|
||||
if (lines) {
|
||||
expect(lines[0]).toContain('Loading...');
|
||||
expect(lines[0]).toContain('Thinking...');
|
||||
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
|
||||
expect(lines[1]).toContain('(esc to cancel, 5s)');
|
||||
expect(lines[2]).toContain('Right');
|
||||
@@ -366,23 +381,87 @@ describe('<LoadingIndicator />', () => {
|
||||
});
|
||||
|
||||
it('should use wide layout at 80 columns', async () => {
|
||||
const { lastFrame, unmount } = await renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
StreamingState.Responding,
|
||||
80,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()?.trim().includes('\n')).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should use narrow layout at 79 columns', async () => {
|
||||
const { lastFrame, unmount } = await renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
StreamingState.Responding,
|
||||
79,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()?.includes('\n')).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render witty phrase after cancel and timer hint in wide layout', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator
|
||||
elapsedTime={5}
|
||||
wittyPhrase="I am witty"
|
||||
showWit={true}
|
||||
currentLoadingPhrase="Thinking..."
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
120,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
// Sequence should be: Primary Text -> Cancel/Timer -> Witty Phrase
|
||||
expect(output).toContain('Thinking... (esc to cancel, 5s) I am witty');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render witty phrase after cancel and timer hint in narrow layout', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
|
||||
<LoadingIndicator
|
||||
elapsedTime={5}
|
||||
wittyPhrase="I am witty"
|
||||
showWit={true}
|
||||
currentLoadingPhrase="Thinking..."
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
79,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
const lines = output?.trim().split('\n');
|
||||
// Expecting 3 lines:
|
||||
// 1. Spinner + Primary Text
|
||||
// 2. Cancel + Timer
|
||||
// 3. Witty Phrase
|
||||
expect(lines).toHaveLength(3);
|
||||
if (lines) {
|
||||
expect(lines[0]).toContain('Thinking...');
|
||||
expect(lines[1]).toContain('(esc to cancel, 5s)');
|
||||
expect(lines[2]).toContain('I am witty');
|
||||
}
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('should use spinnerIcon when provided', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Confirm action',
|
||||
elapsedTime: 10,
|
||||
spinnerIcon: '?',
|
||||
};
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.WaitingForConfirmation,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('?');
|
||||
expect(output).not.toContain('⠏');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,22 +18,34 @@ import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
||||
|
||||
interface LoadingIndicatorProps {
|
||||
currentLoadingPhrase?: string;
|
||||
wittyPhrase?: string;
|
||||
showWit?: boolean;
|
||||
showTips?: boolean;
|
||||
errorVerbosity?: 'low' | 'full';
|
||||
elapsedTime: number;
|
||||
inline?: boolean;
|
||||
rightContent?: React.ReactNode;
|
||||
thought?: ThoughtSummary | null;
|
||||
thoughtLabel?: string;
|
||||
showCancelAndTimer?: boolean;
|
||||
forceRealStatusOnly?: boolean;
|
||||
spinnerIcon?: string;
|
||||
isHookActive?: boolean;
|
||||
}
|
||||
|
||||
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
currentLoadingPhrase,
|
||||
wittyPhrase,
|
||||
showWit = false,
|
||||
elapsedTime,
|
||||
inline = false,
|
||||
rightContent,
|
||||
thought,
|
||||
thoughtLabel,
|
||||
showCancelAndTimer = true,
|
||||
forceRealStatusOnly = false,
|
||||
spinnerIcon,
|
||||
isHookActive = false,
|
||||
}) => {
|
||||
const streamingState = useStreamingContext();
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
@@ -54,15 +66,10 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
? currentLoadingPhrase
|
||||
: thought?.subject
|
||||
? (thoughtLabel ?? thought.subject)
|
||||
: currentLoadingPhrase;
|
||||
const hasThoughtIndicator =
|
||||
currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE &&
|
||||
Boolean(thought?.subject?.trim());
|
||||
// Avoid "Thinking... Thinking..." duplication if primaryText already starts with "Thinking"
|
||||
const thinkingIndicator =
|
||||
hasThoughtIndicator && !primaryText?.startsWith('Thinking')
|
||||
? 'Thinking... '
|
||||
: '';
|
||||
: currentLoadingPhrase ||
|
||||
(streamingState === StreamingState.Responding
|
||||
? 'Thinking...'
|
||||
: undefined);
|
||||
|
||||
const cancelAndTimerContent =
|
||||
showCancelAndTimer &&
|
||||
@@ -70,22 +77,35 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
|
||||
: null;
|
||||
|
||||
const wittyPhraseNode =
|
||||
!forceRealStatusOnly &&
|
||||
showWit &&
|
||||
wittyPhrase &&
|
||||
primaryText === 'Thinking...' ? (
|
||||
<Box marginLeft={1}>
|
||||
<Text color={theme.text.secondary} dimColor italic>
|
||||
{wittyPhrase}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<Box>
|
||||
<Box marginRight={1}>
|
||||
<GeminiRespondingSpinner
|
||||
nonRespondingDisplay={
|
||||
streamingState === StreamingState.WaitingForConfirmation
|
||||
spinnerIcon ??
|
||||
(streamingState === StreamingState.WaitingForConfirmation
|
||||
? '⠏'
|
||||
: ''
|
||||
: '')
|
||||
}
|
||||
isHookActive={isHookActive}
|
||||
/>
|
||||
</Box>
|
||||
{primaryText && (
|
||||
<Box flexShrink={1}>
|
||||
<Text color={theme.text.primary} italic wrap="truncate-end">
|
||||
{thinkingIndicator}
|
||||
{primaryText}
|
||||
</Text>
|
||||
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
|
||||
@@ -102,6 +122,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
|
||||
</>
|
||||
)}
|
||||
{wittyPhraseNode}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -118,16 +139,17 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
<Box marginRight={1}>
|
||||
<GeminiRespondingSpinner
|
||||
nonRespondingDisplay={
|
||||
streamingState === StreamingState.WaitingForConfirmation
|
||||
spinnerIcon ??
|
||||
(streamingState === StreamingState.WaitingForConfirmation
|
||||
? '⠏'
|
||||
: ''
|
||||
: '')
|
||||
}
|
||||
isHookActive={isHookActive}
|
||||
/>
|
||||
</Box>
|
||||
{primaryText && (
|
||||
<Box flexShrink={1}>
|
||||
<Text color={theme.text.primary} italic wrap="truncate-end">
|
||||
{thinkingIndicator}
|
||||
{primaryText}
|
||||
</Text>
|
||||
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
|
||||
@@ -144,6 +166,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
|
||||
</>
|
||||
)}
|
||||
{!isNarrow && wittyPhraseNode}
|
||||
</Box>
|
||||
{!isNarrow && <Box flexGrow={1}>{/* Spacer */}</Box>}
|
||||
{!isNarrow && rightContent && <Box>{rightContent}</Box>}
|
||||
@@ -153,6 +176,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{isNarrow && wittyPhraseNode}
|
||||
{isNarrow && rightContent && <Box>{rightContent}</Box>}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* @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 { cleanUiDetailsVisible, shortcutsHelpVisible } = useUIState();
|
||||
|
||||
if (!cleanUiDetailsVisible) {
|
||||
return <Text color={theme.text.secondary}> press tab twice for more </Text>;
|
||||
}
|
||||
|
||||
const highlightColor = shortcutsHelpVisible
|
||||
? theme.text.accent
|
||||
: theme.text.secondary;
|
||||
|
||||
return <Text color={highlightColor}> ? for shortcuts </Text>;
|
||||
};
|
||||
@@ -11,9 +11,8 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
||||
import { HookStatusDisplay } from './HookStatusDisplay.js';
|
||||
|
||||
interface StatusDisplayProps {
|
||||
export interface StatusDisplayProps {
|
||||
hideContextSummary: boolean;
|
||||
}
|
||||
|
||||
@@ -28,13 +27,6 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
||||
return <Text color={theme.status.error}>|⌐■_■|</Text>;
|
||||
}
|
||||
|
||||
if (
|
||||
uiState.activeHooks.length > 0 &&
|
||||
settings.merged.hooksConfig.notifications
|
||||
) {
|
||||
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
|
||||
}
|
||||
|
||||
if (!settings.merged.ui.hideContextSummary && !hideContextSummary) {
|
||||
return (
|
||||
<ContextSummaryDisplay
|
||||
|
||||
@@ -77,7 +77,7 @@ export const ToastDisplay: React.FC = () => {
|
||||
if (uiState.showIsExpandableHint) {
|
||||
const action = uiState.constrainHeight ? 'show more' : 'collapse';
|
||||
return (
|
||||
<Text color={theme.text.accent}>
|
||||
<Text color={theme.text.secondary}>
|
||||
Press Ctrl+O to {action} lines of the last response
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -11,17 +11,6 @@ Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses default placeholder when not provided 2`] = `
|
||||
"Select your preferred language:
|
||||
|
||||
1. TypeScript
|
||||
2. JavaScript
|
||||
● 3. Enter a custom value
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 1`] = `
|
||||
"Select your preferred language:
|
||||
|
||||
@@ -33,17 +22,6 @@ Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 2`] = `
|
||||
"Select your preferred language:
|
||||
|
||||
1. TypeScript
|
||||
2. JavaScript
|
||||
● 3. Type another language...
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = `
|
||||
"Choose an option
|
||||
|
||||
@@ -60,20 +38,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 2`] = `
|
||||
"Choose an option
|
||||
|
||||
▲
|
||||
● 1. Option 1
|
||||
Description 1
|
||||
2. Option 2
|
||||
Description 2
|
||||
▼
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = `
|
||||
"Choose an option
|
||||
|
||||
@@ -90,45 +54,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 2`] = `
|
||||
"Choose an option
|
||||
|
||||
● 1. Option 1
|
||||
Description 1
|
||||
2. Option 2
|
||||
Description 2
|
||||
3. Option 3
|
||||
Description 3
|
||||
4. Option 4
|
||||
Description 4
|
||||
5. Option 5
|
||||
Description 5
|
||||
6. Option 6
|
||||
Description 6
|
||||
7. Option 7
|
||||
Description 7
|
||||
8. Option 8
|
||||
Description 8
|
||||
9. Option 9
|
||||
Description 9
|
||||
10. Option 10
|
||||
Description 10
|
||||
11. Option 11
|
||||
Description 11
|
||||
12. Option 12
|
||||
Description 12
|
||||
13. Option 13
|
||||
Description 13
|
||||
14. Option 14
|
||||
Description 14
|
||||
15. Option 15
|
||||
Description 15
|
||||
16. Enter a custom value
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
|
||||
"What should we name this component?
|
||||
|
||||
@@ -271,19 +196,3 @@ exports[`AskUserDialog > verifies "All of the above" visual state with snapshot
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > verifies "All of the above" visual state with snapshot 2`] = `
|
||||
"Which features?
|
||||
(Select all that apply)
|
||||
|
||||
1. [x] TypeScript
|
||||
2. [x] ESLint
|
||||
● 3. [x] All of the above
|
||||
Select all options
|
||||
4. [ ] Enter a custom value
|
||||
Done
|
||||
Finish selection
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in idle state 1`] = `
|
||||
" ShortcutsHint
|
||||
"
|
||||
? for shortcuts
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
ApprovalModeIndicator StatusDisplay
|
||||
ApprovalModeIndicator: default StatusDisplay
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
Footer
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in minimal UI mode 1`] = `
|
||||
" ShortcutsHint
|
||||
" press tab twice for more
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in minimal UI mode while loading 1`] = `
|
||||
" LoadingIndicator
|
||||
"LoadingIndicator press tab twice for more
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot in narrow view 1`] = `
|
||||
"
|
||||
ShortcutsHint
|
||||
? for shortcuts
|
||||
────────────────────────────────────────
|
||||
ApprovalModeIndicator
|
||||
|
||||
StatusDisplay
|
||||
ApprovalModeIndicator: StatusDispl
|
||||
default ay
|
||||
InputPrompt: Type your message or
|
||||
@path/to/file
|
||||
Footer
|
||||
@@ -35,9 +35,10 @@ Footer
|
||||
`;
|
||||
|
||||
exports[`Composer > Snapshots > matches snapshot while streaming 1`] = `
|
||||
" LoadingIndicator: Thinking
|
||||
"
|
||||
LoadingIndicator: Thinking ? for shortcuts
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
ApprovalModeIndicator
|
||||
ApprovalModeIndicator: default StatusDisplay
|
||||
InputPrompt: Type your message or @path/to/file
|
||||
Footer
|
||||
"
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
|
||||
exports[`ConfigInitDisplay > handles empty clients map 1`] = `
|
||||
"
|
||||
Spinner Initializing...
|
||||
Spinner Working...
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > renders initial state 1`] = `
|
||||
"
|
||||
Spinner Initializing...
|
||||
Spinner Working...
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 1`] = `
|
||||
"
|
||||
Spinner Initializing...
|
||||
Spinner Working...
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
|
||||
"
|
||||
Spinner Initializing...
|
||||
Spinner Working...
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ContextSummaryDisplay /> > should not render empty parts 1`] = `
|
||||
" - 1 open file (ctrl+g to view)
|
||||
" 1 open file (ctrl+g to view)
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ContextSummaryDisplay /> > should render on a single line on a wide screen 1`] = `
|
||||
" 1 open file (ctrl+g to view) | 1 GEMINI.md file | 1 MCP server | 1 skill
|
||||
" 1 open file (ctrl+g to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<ContextSummaryDisplay /> > should render on multiple lines on a narrow screen 1`] = `
|
||||
" - 1 open file (ctrl+g to view)
|
||||
- 1 GEMINI.md file
|
||||
- 1 MCP server
|
||||
- 1 skill
|
||||
" 1 open file (ctrl+g to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -27,33 +27,6 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"Overview
|
||||
|
||||
@@ -81,33 +54,6 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = `
|
||||
" Error reading plan: File not found
|
||||
"
|
||||
@@ -194,33 +140,6 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"Overview
|
||||
|
||||
@@ -248,33 +167,6 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = `
|
||||
" Error reading plan: File not found
|
||||
"
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="37" viewBox="0 0 920 37">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="37" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#afafaf" textLength="225" lengthAdjust="spacingAndGlyphs" font-style="italic">Executing Hook: test-hook</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 479 B |
@@ -1,5 +1,7 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<HookStatusDisplay /> > matches SVG snapshot for single hook 1`] = `"Executing Hook: test-hook"`;
|
||||
|
||||
exports[`<HookStatusDisplay /> > should render a single executing hook 1`] = `
|
||||
"Executing Hook: test-hook
|
||||
"
|
||||
|
||||
@@ -78,27 +78,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Pasted Text: 10 lines]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Pasted Text: 10 lines]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Pasted Text: 10 lines]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Type your message or @path/to/file
|
||||
|
||||
@@ -11,7 +11,7 @@ exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `
|
||||
`;
|
||||
|
||||
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `
|
||||
"Mock Hook Status Display
|
||||
"Mock Context Summary Display (Skills: 2, Shells: 0)
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -10,10 +10,12 @@ import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface HorizontalLineProps {
|
||||
color?: string;
|
||||
dim?: boolean;
|
||||
}
|
||||
|
||||
export const HorizontalLine: React.FC<HorizontalLineProps> = ({
|
||||
color = theme.border.default,
|
||||
dim = false,
|
||||
}) => (
|
||||
<Box
|
||||
width="100%"
|
||||
@@ -23,5 +25,6 @@ export const HorizontalLine: React.FC<HorizontalLineProps> = ({
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={color}
|
||||
borderDimColor={dim}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user