feat(ui): add visual indicators for hook execution (#15408)

This commit is contained in:
Abhi
2026-01-06 15:52:12 -05:00
committed by GitHub
parent 86b5995f12
commit 61dbab03e0
27 changed files with 1124 additions and 73 deletions

View File

@@ -36,6 +36,10 @@ vi.mock('./ContextSummaryDisplay.js', () => ({
ContextSummaryDisplay: () => <Text>ContextSummaryDisplay</Text>,
}));
vi.mock('./HookStatusDisplay.js', () => ({
HookStatusDisplay: () => <Text>HookStatusDisplay</Text>,
}));
vi.mock('./AutoAcceptIndicator.js', () => ({
AutoAcceptIndicator: () => <Text>AutoAcceptIndicator</Text>,
}));
@@ -125,6 +129,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
errorCount: 0,
nightly: false,
isTrustedFolder: true,
activeHooks: [],
...overrides,
}) as UIState;
@@ -341,6 +346,17 @@ describe('Composer', () => {
expect(lastFrame()).toContain('ContextSummaryDisplay');
});
it('renders HookStatusDisplay instead of ContextSummaryDisplay with active hooks', () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'test-hook', eventName: 'before-agent' }],
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('HookStatusDisplay');
expect(lastFrame()).not.toContain('ContextSummaryDisplay');
});
it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: true,

View File

@@ -5,9 +5,9 @@
*/
import { useState } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { Box, useIsScreenReaderEnabled } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import { StatusDisplay } from './StatusDisplay.js';
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
@@ -17,7 +17,6 @@ import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { theme } from '../semantic-colors.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
@@ -43,7 +42,7 @@ export const Composer = () => {
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
const isAlternateBuffer = useAlternateBuffer();
const { contextFileNames, showAutoAcceptIndicator } = uiState;
const { showAutoAcceptIndicator } = uiState;
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
const hideContextSummary =
suggestionsVisible && suggestionsPosition === 'above';
@@ -92,38 +91,7 @@ export const Composer = () => {
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box marginRight={1}>
{process.env['GEMINI_SYSTEM_MD'] && (
<Text color={theme.status.error}>|_| </Text>
)}
{uiState.ctrlCPressedOnce ? (
<Text color={theme.status.warning}>
Press Ctrl+C again to exit.
</Text>
) : uiState.warningMessage ? (
<Text color={theme.status.warning}>{uiState.warningMessage}</Text>
) : uiState.ctrlDPressedOnce ? (
<Text color={theme.status.warning}>
Press Ctrl+D again to exit.
</Text>
) : uiState.showEscapePrompt ? (
<Text color={theme.text.secondary}>Press Esc again to clear.</Text>
) : uiState.queueErrorMessage ? (
<Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>
) : (
!settings.merged.ui?.hideContextSummary &&
!hideContextSummary && (
<ContextSummaryDisplay
ideContext={uiState.ideContextState}
geminiMdFileCount={uiState.geminiMdFileCount}
contextFileNames={contextFileNames}
mcpServers={config.getMcpClientManager()?.getMcpServers() ?? {}}
blockedMcpServers={
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
}
skillCount={config.getSkillManager().getSkills().length}
/>
)
)}
<StatusDisplay hideContextSummary={hideContextSummary} />
</Box>
<Box paddingTop={isNarrow ? 1 : 0}>
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&

View File

@@ -6,7 +6,7 @@
import type React from 'react';
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
@@ -16,6 +16,11 @@ vi.mock('../hooks/useTerminalSize.js', () => ({
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
const renderWithWidth = (
width: number,
props: React.ComponentProps<typeof ContextSummaryDisplay>,
@@ -26,48 +31,68 @@ const renderWithWidth = (
describe('<ContextSummaryDisplay />', () => {
const baseProps = {
geminiMdFileCount: 1,
contextFileNames: ['GEMINI.md'],
mcpServers: { 'test-server': { command: 'test' } },
geminiMdFileCount: 0,
contextFileNames: [],
mcpServers: {},
ideContext: {
workspaceState: {
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
openFiles: [],
},
},
skillCount: 1,
};
it('should render on a single line on a wide screen', () => {
const { lastFrame, unmount } = renderWithWidth(120, baseProps);
const output = lastFrame()!;
expect(output).toContain(
'1 open file (ctrl+g to view) | 1 GEMINI.md file | 1 MCP server | 1 skill',
);
expect(output).not.toContain('Using:');
// Check for absence of newlines
expect(output.includes('\n')).toBe(false);
const props = {
...baseProps,
geminiMdFileCount: 1,
contextFileNames: ['GEMINI.md'],
mcpServers: { 'test-server': { command: 'test' } },
ideContext: {
workspaceState: {
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
},
},
};
const { lastFrame, unmount } = renderWithWidth(120, props);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render on multiple lines on a narrow screen', () => {
const { lastFrame, unmount } = renderWithWidth(60, baseProps);
const output = lastFrame()!;
const expectedLines = [
' - 1 open file (ctrl+g to view)',
' - 1 GEMINI.md file',
' - 1 MCP server',
' - 1 skill',
];
const actualLines = output.split('\n');
expect(actualLines).toEqual(expectedLines);
const props = {
...baseProps,
geminiMdFileCount: 1,
contextFileNames: ['GEMINI.md'],
mcpServers: { 'test-server': { command: 'test' } },
ideContext: {
workspaceState: {
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
},
},
};
const { lastFrame, unmount } = renderWithWidth(60, props);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should switch layout at the 80-column breakpoint', () => {
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 } = renderWithWidth(
80,
baseProps,
props,
);
expect(wideFrame()!.includes('\n')).toBe(false);
unmountWide();
@@ -75,13 +100,12 @@ describe('<ContextSummaryDisplay />', () => {
// At 79 columns, should be on multiple lines
const { lastFrame: narrowFrame, unmount: unmountNarrow } = renderWithWidth(
79,
baseProps,
props,
);
expect(narrowFrame()!.includes('\n')).toBe(true);
expect(narrowFrame()!.split('\n').length).toBe(4);
unmountNarrow();
});
it('should not render empty parts', () => {
const props = {
...baseProps,
@@ -89,11 +113,14 @@ describe('<ContextSummaryDisplay />', () => {
contextFileNames: [],
mcpServers: {},
skillCount: 0,
ideContext: {
workspaceState: {
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
},
},
};
const { lastFrame, unmount } = renderWithWidth(60, props);
const expectedLines = [' - 1 open file (ctrl+g to view)'];
const actualLines = lastFrame()!.split('\n');
expect(actualLines).toEqual(expectedLines);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});

View File

@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { HookStatusDisplay } from './HookStatusDisplay.js';
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('<HookStatusDisplay />', () => {
it('should render a single executing hook', () => {
const props = {
activeHooks: [{ name: 'test-hook', eventName: 'BeforeAgent' }],
};
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render multiple executing hooks', () => {
const props = {
activeHooks: [
{ name: 'h1', eventName: 'BeforeAgent' },
{ name: 'h2', eventName: 'BeforeAgent' },
],
};
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render sequential hook progress', () => {
const props = {
activeHooks: [
{ name: 'step', eventName: 'BeforeAgent', index: 1, total: 3 },
],
};
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should return empty string if no active hooks', () => {
const props = { activeHooks: [] };
const { lastFrame, unmount } = render(<HookStatusDisplay {...props} />);
expect(lastFrame()).toBe('');
unmount();
});
});

View File

@@ -0,0 +1,39 @@
/**
* @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 { type ActiveHook } from '../types.js';
interface HookStatusDisplayProps {
activeHooks: ActiveHook[];
}
export const HookStatusDisplay: React.FC<HookStatusDisplayProps> = ({
activeHooks,
}) => {
if (activeHooks.length === 0) {
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 text = `${label}: ${displayNames.join(', ')}`;
return (
<Text color={theme.status.warning} wrap="truncate">
{text}
</Text>
);
};

View File

@@ -0,0 +1,208 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render } from '../../test-utils/render.js';
import { Text } from 'ink';
import { StatusDisplay } from './StatusDisplay.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
// Mock child components to simplify testing
vi.mock('./ContextSummaryDisplay.js', () => ({
ContextSummaryDisplay: (props: { skillCount: number }) => (
<Text>Mock Context Summary Display (Skills: {props.skillCount})</Text>
),
}));
vi.mock('./HookStatusDisplay.js', () => ({
HookStatusDisplay: () => <Text>Mock Hook Status Display</Text>,
}));
// Create mock context providers
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
({
ctrlCPressedOnce: false,
warningMessage: null,
ctrlDPressedOnce: false,
showEscapePrompt: false,
queueErrorMessage: null,
activeHooks: [],
ideContextState: null,
geminiMdFileCount: 0,
contextFileNames: [],
...overrides,
}) as UIState;
const createMockConfig = (overrides = {}) => ({
getMcpClientManager: vi.fn().mockImplementation(() => ({
getBlockedMcpServers: vi.fn(() => []),
getMcpServers: vi.fn(() => ({})),
})),
getSkillManager: vi.fn().mockImplementation(() => ({
getSkills: vi.fn(() => ['skill1', 'skill2']),
})),
...overrides,
});
const createMockSettings = (merged = {}) => ({
merged: {
hooks: { notifications: true },
ui: { hideContextSummary: false },
...merged,
},
});
/* eslint-disable @typescript-eslint/no-explicit-any */
const renderStatusDisplay = (
props: { hideContextSummary: boolean } = { hideContextSummary: false },
uiState: UIState = createMockUIState(),
settings = createMockSettings(),
config = createMockConfig(),
) =>
render(
<ConfigContext.Provider value={config as any}>
<SettingsContext.Provider value={settings as any}>
<UIStateContext.Provider value={uiState}>
<StatusDisplay {...props} />
</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
/* eslint-enable @typescript-eslint/no-explicit-any */
describe('StatusDisplay', () => {
const originalEnv = process.env;
afterEach(() => {
process.env = { ...originalEnv };
delete process.env['GEMINI_SYSTEM_MD'];
});
it('renders nothing by default if context summary is hidden via props', () => {
const { lastFrame } = renderStatusDisplay({ hideContextSummary: true });
expect(lastFrame()).toBe('');
});
it('renders ContextSummaryDisplay by default', () => {
const { lastFrame } = renderStatusDisplay();
expect(lastFrame()).toMatchSnapshot();
});
it('renders system md indicator if env var is set', () => {
process.env['GEMINI_SYSTEM_MD'] = 'true';
const { lastFrame } = renderStatusDisplay();
expect(lastFrame()).toMatchSnapshot();
});
it('prioritizes Ctrl+C prompt over everything else (except system md)', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: true,
warningMessage: 'Warning',
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders warning message', () => {
const uiState = createMockUIState({
warningMessage: 'This is a warning',
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('prioritizes warning over Ctrl+D', () => {
const uiState = createMockUIState({
warningMessage: 'Warning',
ctrlDPressedOnce: true,
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders Ctrl+D prompt', () => {
const uiState = createMockUIState({
ctrlDPressedOnce: true,
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders Queue Error Message', () => {
const uiState = createMockUIState({
queueErrorMessage: 'Queue Error',
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders HookStatusDisplay when hooks are active', () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('does NOT render HookStatusDisplay if notifications are disabled in settings', () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const settings = createMockSettings({
hooks: { notifications: false },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
settings,
);
expect(lastFrame()).toMatchSnapshot();
});
it('hides ContextSummaryDisplay if configured in settings', () => {
const settings = createMockSettings({
ui: { hideContextSummary: true },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
undefined,
settings,
);
expect(lastFrame()).toBe('');
});
});

View File

@@ -0,0 +1,78 @@
/**
* @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';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import { HookStatusDisplay } from './HookStatusDisplay.js';
interface StatusDisplayProps {
hideContextSummary: boolean;
}
export const StatusDisplay: React.FC<StatusDisplayProps> = ({
hideContextSummary,
}) => {
const uiState = useUIState();
const settings = useSettings();
const config = useConfig();
if (process.env['GEMINI_SYSTEM_MD']) {
return <Text color={theme.status.error}>|_| </Text>;
}
if (uiState.ctrlCPressedOnce) {
return (
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
);
}
if (uiState.warningMessage) {
return <Text color={theme.status.warning}>{uiState.warningMessage}</Text>;
}
if (uiState.ctrlDPressedOnce) {
return (
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
);
}
if (uiState.showEscapePrompt) {
return <Text color={theme.text.secondary}>Press Esc again to clear.</Text>;
}
if (uiState.queueErrorMessage) {
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
if (
uiState.activeHooks.length > 0 &&
(settings.merged.hooks?.notifications ?? true)
) {
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
}
if (!settings.merged.ui?.hideContextSummary && !hideContextSummary) {
return (
<ContextSummaryDisplay
ideContext={uiState.ideContextState}
geminiMdFileCount={uiState.geminiMdFileCount}
contextFileNames={uiState.contextFileNames}
mcpServers={config.getMcpClientManager()?.getMcpServers() ?? {}}
blockedMcpServers={
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
}
skillCount={config.getSkillManager().getSkills().length}
/>
);
}
return null;
};

View File

@@ -0,0 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ContextSummaryDisplay /> > should not render empty parts 1`] = `" - 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"`;
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"
`;

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<HookStatusDisplay /> > should render a single executing hook 1`] = `"Executing Hook: test-hook"`;
exports[`<HookStatusDisplay /> > should render multiple executing hooks 1`] = `"Executing Hooks: h1, h2"`;
exports[`<HookStatusDisplay /> > should render sequential hook progress 1`] = `"Executing Hook: step (1/3)"`;

View File

@@ -0,0 +1,21 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2)"`;
exports[`StatusDisplay > prioritizes Ctrl+C prompt over everything else (except system md) 1`] = `"Press Ctrl+C again to exit."`;
exports[`StatusDisplay > prioritizes warning over Ctrl+D 1`] = `"Warning"`;
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2)"`;
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
exports[`StatusDisplay > renders Escape prompt 1`] = `"Press Esc again to clear."`;
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `"Mock Hook Status Display"`;
exports[`StatusDisplay > renders Queue Error Message 1`] = `"Queue Error"`;
exports[`StatusDisplay > renders system md indicator if env var is set 1`] = `"|⌐■_■|"`;
exports[`StatusDisplay > renders warning message 1`] = `"This is a warning"`;