bug(ui) make it clear when users need to enter selection mode and fix clear issue. (#13083)

This commit is contained in:
Jacob Richman
2025-11-14 12:02:15 -08:00
committed by GitHub
parent d683e1c0db
commit ba15eeb55f
14 changed files with 320 additions and 57 deletions
+26 -3
View File
@@ -109,7 +109,7 @@ import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js';
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
import { useSettings } from './contexts/SettingsContext.js'; import { useSettings } from './contexts/SettingsContext.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000; const WARNING_PROMPT_DURATION_MS = 1000;
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
@@ -892,6 +892,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
>(); >();
const [showEscapePrompt, setShowEscapePrompt] = useState(false); const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
const [selectionWarning, setSelectionWarning] = useState(false);
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
@@ -901,6 +902,26 @@ Logging in with Google... Please restart Gemini CLI to continue.
} = useIdeTrustListener(); } = useIdeTrustListener();
const isInitialMount = useRef(true); const isInitialMount = useRef(true);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
const handleSelectionWarning = () => {
setSelectionWarning(true);
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
setSelectionWarning(false);
}, WARNING_PROMPT_DURATION_MS);
};
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
return () => {
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, []);
useEffect(() => { useEffect(() => {
if (ideNeedsRestart) { if (ideNeedsRestart) {
// IDE trust changed, force a restart. // IDE trust changed, force a restart.
@@ -976,7 +997,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
ctrlCTimerRef.current = setTimeout(() => { ctrlCTimerRef.current = setTimeout(() => {
setCtrlCPressCount(0); setCtrlCPressCount(0);
ctrlCTimerRef.current = null; ctrlCTimerRef.current = null;
}, CTRL_EXIT_PROMPT_DURATION_MS); }, WARNING_PROMPT_DURATION_MS);
} }
}, [ctrlCPressCount, config, setCtrlCPressCount, handleSlashCommand]); }, [ctrlCPressCount, config, setCtrlCPressCount, handleSlashCommand]);
@@ -994,7 +1015,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
ctrlDTimerRef.current = setTimeout(() => { ctrlDTimerRef.current = setTimeout(() => {
setCtrlDPressCount(0); setCtrlDPressCount(0);
ctrlDTimerRef.current = null; ctrlDTimerRef.current = null;
}, CTRL_EXIT_PROMPT_DURATION_MS); }, WARNING_PROMPT_DURATION_MS);
} }
}, [ctrlDPressCount, config, setCtrlDPressCount, handleSlashCommand]); }, [ctrlDPressCount, config, setCtrlDPressCount, handleSlashCommand]);
@@ -1345,6 +1366,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
embeddedShellFocused, embeddedShellFocused,
showDebugProfiler, showDebugProfiler,
copyModeEnabled, copyModeEnabled,
selectionWarning,
}), }),
[ [
isThemeDialogOpen, isThemeDialogOpen,
@@ -1430,6 +1452,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
apiKeyDefaultValue, apiKeyDefaultValue,
authState, authState,
copyModeEnabled, copyModeEnabled,
selectionWarning,
], ],
); );
@@ -99,6 +99,10 @@ export const Composer = () => {
<Text color={theme.status.warning}> <Text color={theme.status.warning}>
Press Ctrl+C again to exit. Press Ctrl+C again to exit.
</Text> </Text>
) : uiState.selectionWarning ? (
<Text color={theme.status.warning}>
Press Ctrl-S to enter selection mode to copy text.
</Text>
) : uiState.ctrlDPressedOnce ? ( ) : uiState.ctrlDPressedOnce ? (
<Text color={theme.status.warning}> <Text color={theme.status.warning}>
Press Ctrl+D again to exit. Press Ctrl+D again to exit.
@@ -60,4 +60,15 @@ describe('Help Component', () => {
expect(output).not.toContain('hidden-child'); expect(output).not.toContain('hidden-child');
unmount(); unmount();
}); });
it('should render keyboard shortcuts', () => {
const { lastFrame, unmount } = render(<Help commands={mockCommands} />);
const output = lastFrame();
expect(output).toContain('Keyboard Shortcuts:');
expect(output).toContain('Ctrl+C');
expect(output).toContain('Ctrl+S');
expect(output).toContain('Page Up/Down');
unmount();
});
}); });
+12
View File
@@ -136,6 +136,12 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>{' '} </Text>{' '}
- Clear the screen - Clear the screen
</Text> </Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Ctrl+S
</Text>{' '}
- Enter selection mode to copy text
</Text>
<Text color={theme.text.primary}> <Text color={theme.text.primary}>
<Text bold color={theme.text.accent}> <Text bold color={theme.text.accent}>
{process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'} {process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'}
@@ -160,6 +166,12 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>{' '} </Text>{' '}
- Cancel operation / Clear input (double press) - Cancel operation / Clear input (double press)
</Text> </Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Page Up/Down
</Text>{' '}
- Scroll page up/down
</Text>
<Text color={theme.text.primary}> <Text color={theme.text.primary}>
<Text bold color={theme.text.accent}> <Text bold color={theme.text.accent}>
Shift+Tab Shift+Tab
@@ -379,8 +379,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const relY = mouseY - y; const relY = mouseY - y;
const visualRow = buffer.visualScrollRow + relY; const visualRow = buffer.visualScrollRow + relY;
buffer.moveToVisualPosition(visualRow, relX); buffer.moveToVisualPosition(visualRow, relX);
return true;
} }
} }
return false;
}, },
[buffer], [buffer],
); );
@@ -11,6 +11,7 @@ import { vi, type Mock } from 'vitest';
import type React from 'react'; import type React from 'react';
import { useStdin } from 'ink'; import { useStdin } from 'ink';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { appEvents, AppEvent } from '../../utils/events.js';
// Mock the 'ink' module to control stdin // Mock the 'ink' module to control stdin
vi.mock('ink', async (importOriginal) => { vi.mock('ink', async (importOriginal) => {
@@ -21,6 +22,18 @@ vi.mock('ink', async (importOriginal) => {
}; };
}); });
// Mock appEvents
vi.mock('../../utils/events.js', () => ({
appEvents: {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
},
AppEvent: {
SelectionWarning: 'selection-warning',
},
}));
class MockStdin extends EventEmitter { class MockStdin extends EventEmitter {
isTTY = true; isTTY = true;
setRawMode = vi.fn(); setRawMode = vi.fn();
@@ -47,6 +60,7 @@ describe('MouseContext', () => {
wrapper = ({ children }: { children: React.ReactNode }) => ( wrapper = ({ children }: { children: React.ReactNode }) => (
<MouseProvider mouseEventsEnabled={true}>{children}</MouseProvider> <MouseProvider mouseEventsEnabled={true}>{children}</MouseProvider>
); );
vi.mocked(appEvents.emit).mockClear();
}); });
afterEach(() => { afterEach(() => {
@@ -91,6 +105,34 @@ describe('MouseContext', () => {
expect(handler).not.toHaveBeenCalled(); expect(handler).not.toHaveBeenCalled();
}); });
it('should emit SelectionWarning when move event is unhandled and has coordinates', () => {
renderHook(() => useMouseContext(), { wrapper });
act(() => {
// Move event (32) at 10, 20
stdin.write('\x1b[<32;10;20M');
});
expect(appEvents.emit).toHaveBeenCalledWith(AppEvent.SelectionWarning);
});
it('should not emit SelectionWarning when move event is handled', () => {
const handler = vi.fn().mockReturnValue(true);
const { result } = renderHook(() => useMouseContext(), { wrapper });
act(() => {
result.current.subscribe(handler);
});
act(() => {
// Move event (32) at 10, 20
stdin.write('\x1b[<32;10;20M');
});
expect(handler).toHaveBeenCalled();
expect(appEvents.emit).not.toHaveBeenCalled();
});
describe('SGR Mouse Events', () => { describe('SGR Mouse Events', () => {
it.each([ it.each([
{ {
+17 -1
View File
@@ -15,6 +15,7 @@ import {
} from 'react'; } from 'react';
import { ESC } from '../utils/input.js'; import { ESC } from '../utils/input.js';
import { debugLogger } from '@google/gemini-cli-core'; import { debugLogger } from '@google/gemini-cli-core';
import { appEvents, AppEvent } from '../../utils/events.js';
import { import {
isIncompleteMouseSequence, isIncompleteMouseSequence,
parseMouseEvent, parseMouseEvent,
@@ -89,8 +90,23 @@ export function MouseProvider({
let mouseBuffer = ''; let mouseBuffer = '';
const broadcast = (event: MouseEvent) => { const broadcast = (event: MouseEvent) => {
let handled = false;
for (const handler of subscribers) { for (const handler of subscribers) {
handler(event); if (handler(event) === true) {
handled = true;
}
}
if (
!handled &&
event.name === 'move' &&
event.col >= 0 &&
event.row >= 0
) {
// Terminal apps only receive mouse move events when the mouse is down
// so this always indicates a mouse drag that the user was expecting
// would trigger text selection but does not as we are handling mouse
// events not the terminal.
appEvents.emit(AppEvent.SelectionWarning);
} }
}; };
@@ -16,12 +16,12 @@ import { Box, type DOMElement } from 'ink';
import type { MouseEvent } from '../hooks/useMouse.js'; import type { MouseEvent } from '../hooks/useMouse.js';
// Mock useMouse hook // Mock useMouse hook
const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void>(); const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void | boolean>();
vi.mock('../hooks/useMouse.js', async () => { vi.mock('../hooks/useMouse.js', async () => {
// We need to import React dynamically because this factory runs before top-level imports // We need to import React dynamically because this factory runs before top-level imports
const React = await import('react'); const React = await import('react');
return { return {
useMouse: (callback: (event: MouseEvent) => void) => { useMouse: (callback: (event: MouseEvent) => void | boolean) => {
React.useEffect(() => { React.useEffect(() => {
mockUseMouseCallbacks.add(callback); mockUseMouseCallbacks.add(callback);
return () => { return () => {
@@ -81,6 +81,81 @@ describe('ScrollProvider', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
describe('Event Handling Status', () => {
it('returns true when scroll event is handled', () => {
const scrollBy = vi.fn();
const getScrollState = vi.fn(() => ({
scrollTop: 0,
scrollHeight: 100,
innerHeight: 10,
}));
render(
<ScrollProvider>
<TestScrollable
id="test-scrollable"
scrollBy={scrollBy}
getScrollState={getScrollState}
/>
</ScrollProvider>,
);
let handled = false;
for (const callback of mockUseMouseCallbacks) {
if (
callback({
name: 'scroll-down',
col: 5,
row: 5,
shift: false,
ctrl: false,
meta: false,
}) === true
) {
handled = true;
}
}
expect(handled).toBe(true);
});
it('returns false when scroll event is ignored (cannot scroll further)', () => {
const scrollBy = vi.fn();
// Already at bottom
const getScrollState = vi.fn(() => ({
scrollTop: 90,
scrollHeight: 100,
innerHeight: 10,
}));
render(
<ScrollProvider>
<TestScrollable
id="test-scrollable"
scrollBy={scrollBy}
getScrollState={getScrollState}
/>
</ScrollProvider>,
);
let handled = false;
for (const callback of mockUseMouseCallbacks) {
if (
callback({
name: 'scroll-down',
col: 5,
row: 5,
shift: false,
ctrl: false,
meta: false,
}) === true
) {
handled = true;
}
}
expect(handled).toBe(false);
});
});
it('calls scrollTo when clicking scrollbar track if available', async () => { it('calls scrollTo when clicking scrollbar track if available', async () => {
const scrollBy = vi.fn(); const scrollBy = vi.fn();
const scrollTo = vi.fn(); const scrollTo = vi.fn();
+23 -12
View File
@@ -146,15 +146,16 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
if (direction === 'up' && canScrollUp) { if (direction === 'up' && canScrollUp) {
pendingScrollsRef.current.set(candidate.id, pendingDelta + delta); pendingScrollsRef.current.set(candidate.id, pendingDelta + delta);
scheduleFlush(); scheduleFlush();
return; return true;
} }
if (direction === 'down' && canScrollDown) { if (direction === 'down' && canScrollDown) {
pendingScrollsRef.current.set(candidate.id, pendingDelta + delta); pendingScrollsRef.current.set(candidate.id, pendingDelta + delta);
scheduleFlush(); scheduleFlush();
return; return true;
} }
} }
return false;
}; };
const handleLeftPress = (mouseEvent: MouseEvent) => { const handleLeftPress = (mouseEvent: MouseEvent) => {
@@ -238,7 +239,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
id: entry.id, id: entry.id,
offset, offset,
}; };
return; return true;
} }
} }
@@ -250,21 +251,27 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
if (candidates.length > 0) { if (candidates.length > 0) {
// The first candidate is the innermost one. // The first candidate is the innermost one.
candidates[0].flashScrollbar(); candidates[0].flashScrollbar();
// We don't consider just flashing the scrollbar as handling the event
// in a way that should prevent other handlers (like drag warning)
// from checking it, although for left-press it doesn't matter much.
// But returning false is safer.
return false;
} }
return false;
}; };
const handleMove = (mouseEvent: MouseEvent) => { const handleMove = (mouseEvent: MouseEvent) => {
const state = dragStateRef.current; const state = dragStateRef.current;
if (!state.active || !state.id) return; if (!state.active || !state.id) return false;
const entry = scrollablesRef.current.get(state.id); const entry = scrollablesRef.current.get(state.id);
if (!entry || !entry.ref.current) { if (!entry || !entry.ref.current) {
state.active = false; state.active = false;
return; return false;
} }
const boundingBox = getBoundingBox(entry.ref.current); const boundingBox = getBoundingBox(entry.ref.current);
if (!boundingBox) return; if (!boundingBox) return false;
const { y } = boundingBox; const { y } = boundingBox;
const { scrollTop, scrollHeight, innerHeight } = entry.getScrollState(); const { scrollTop, scrollHeight, innerHeight } = entry.getScrollState();
@@ -276,7 +283,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
const maxScrollTop = scrollHeight - innerHeight; const maxScrollTop = scrollHeight - innerHeight;
const maxThumbY = innerHeight - thumbHeight; const maxThumbY = innerHeight - thumbHeight;
if (maxThumbY <= 0) return; if (maxThumbY <= 0) return false;
const relativeMouseY = mouseEvent.row - y; const relativeMouseY = mouseEvent.row - y;
// Calculate the target thumb position based on the mouse position and the offset. // Calculate the target thumb position based on the mouse position and the offset.
@@ -295,6 +302,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
} else { } else {
entry.scrollBy(targetScrollTop - scrollTop); entry.scrollBy(targetScrollTop - scrollTop);
} }
return true;
}; };
const handleLeftRelease = () => { const handleLeftRelease = () => {
@@ -304,22 +312,25 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
id: null, id: null,
offset: 0, offset: 0,
}; };
return true;
} }
return false;
}; };
useMouse( useMouse(
(event: MouseEvent) => { (event: MouseEvent) => {
if (event.name === 'scroll-up') { if (event.name === 'scroll-up') {
handleScroll('up', event); return handleScroll('up', event);
} else if (event.name === 'scroll-down') { } else if (event.name === 'scroll-down') {
handleScroll('down', event); return handleScroll('down', event);
} else if (event.name === 'left-press') { } else if (event.name === 'left-press') {
handleLeftPress(event); return handleLeftPress(event);
} else if (event.name === 'move') { } else if (event.name === 'move') {
handleMove(event); return handleMove(event);
} else if (event.name === 'left-release') { } else if (event.name === 'left-release') {
handleLeftRelease(); return handleLeftRelease();
} }
return false;
}, },
{ isActive: true }, { isActive: true },
); );
@@ -124,6 +124,7 @@ export interface UIState {
showDebugProfiler: boolean; showDebugProfiler: boolean;
showFullTodos: boolean; showFullTodos: boolean;
copyModeEnabled: boolean; copyModeEnabled: boolean;
selectionWarning: boolean;
} }
export const UIStateContext = createContext<UIState | null>(null); export const UIStateContext = createContext<UIState | null>(null);
@@ -28,8 +28,27 @@ import {
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { appEvents } from '../../utils/events.js'; import { appEvents } from '../../utils/events.js';
const { logSlashCommand } = vi.hoisted(() => ({ const {
logSlashCommand,
mockBuiltinLoadCommands,
mockFileLoadCommands,
mockMcpLoadCommands,
mockIdeClientGetInstance,
mockUseAlternateBuffer,
} = vi.hoisted(() => ({
logSlashCommand: vi.fn(), logSlashCommand: vi.fn(),
mockBuiltinLoadCommands: vi.fn().mockResolvedValue([]),
mockFileLoadCommands: vi.fn().mockResolvedValue([]),
mockMcpLoadCommands: vi.fn().mockResolvedValue([]),
mockIdeClientGetInstance: vi.fn().mockResolvedValue({
addStatusChangeListener: vi.fn(),
removeStatusChangeListener: vi.fn(),
}),
mockUseAlternateBuffer: vi.fn().mockReturnValue(false),
}));
vi.mock('./useAlternateBuffer.js', () => ({
useAlternateBuffer: mockUseAlternateBuffer,
})); }));
vi.mock('@google/gemini-cli-core', async (importOriginal) => { vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -41,10 +60,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
logSlashCommand, logSlashCommand,
getIdeInstaller: vi.fn().mockReturnValue(null), getIdeInstaller: vi.fn().mockReturnValue(null),
IdeClient: { IdeClient: {
getInstance: vi.fn().mockResolvedValue({ getInstance: mockIdeClientGetInstance,
addStatusChangeListener: vi.fn(),
removeStatusChangeListener: vi.fn(),
}),
}, },
}; };
}); });
@@ -65,23 +81,20 @@ vi.mock('node:process', () => {
}; };
}); });
const mockBuiltinLoadCommands = vi.fn();
vi.mock('../../services/BuiltinCommandLoader.js', () => ({ vi.mock('../../services/BuiltinCommandLoader.js', () => ({
BuiltinCommandLoader: vi.fn().mockImplementation(() => ({ BuiltinCommandLoader: vi.fn(() => ({
loadCommands: mockBuiltinLoadCommands, loadCommands: mockBuiltinLoadCommands,
})), })),
})); }));
const mockFileLoadCommands = vi.fn();
vi.mock('../../services/FileCommandLoader.js', () => ({ vi.mock('../../services/FileCommandLoader.js', () => ({
FileCommandLoader: vi.fn().mockImplementation(() => ({ FileCommandLoader: vi.fn(() => ({
loadCommands: mockFileLoadCommands, loadCommands: mockFileLoadCommands,
})), })),
})); }));
const mockMcpLoadCommands = vi.fn();
vi.mock('../../services/McpPromptLoader.js', () => ({ vi.mock('../../services/McpPromptLoader.js', () => ({
McpPromptLoader: vi.fn().mockImplementation(() => ({ McpPromptLoader: vi.fn(() => ({
loadCommands: mockMcpLoadCommands, loadCommands: mockMcpLoadCommands,
})), })),
})); }));
@@ -130,6 +143,12 @@ describe('useSlashCommandProcessor', () => {
mockBuiltinLoadCommands.mockResolvedValue([]); mockBuiltinLoadCommands.mockResolvedValue([]);
mockFileLoadCommands.mockResolvedValue([]); mockFileLoadCommands.mockResolvedValue([]);
mockMcpLoadCommands.mockResolvedValue([]); mockMcpLoadCommands.mockResolvedValue([]);
mockUseAlternateBuffer.mockReturnValue(false);
mockIdeClientGetInstance.mockResolvedValue({
addStatusChangeListener: vi.fn(),
removeStatusChangeListener: vi.fn(),
});
vi.spyOn(console, 'clear').mockImplementation(() => {});
}); });
afterEach(async () => { afterEach(async () => {
@@ -137,6 +156,7 @@ describe('useSlashCommandProcessor', () => {
await unmountHook(); await unmountHook();
unmountHook = undefined; unmountHook = undefined;
} }
vi.restoreAllMocks();
}); });
const setupProcessorHook = async ( const setupProcessorHook = async (
@@ -205,6 +225,44 @@ describe('useSlashCommandProcessor', () => {
}; };
}; };
describe('Console Clear Safety', () => {
it('should not call console.clear if alternate buffer is active', async () => {
mockUseAlternateBuffer.mockReturnValue(true);
const clearCommand = createTestCommand({
name: 'clear',
action: async (context) => {
context.ui.clear();
},
});
const result = await setupProcessorHook([clearCommand]);
await act(async () => {
await result.current.handleSlashCommand('/clear');
});
expect(mockClearItems).toHaveBeenCalled();
expect(console.clear).not.toHaveBeenCalled();
});
it('should call console.clear if alternate buffer is not active', async () => {
mockUseAlternateBuffer.mockReturnValue(false);
const clearCommand = createTestCommand({
name: 'clear',
action: async (context) => {
context.ui.clear();
},
});
const result = await setupProcessorHook([clearCommand]);
await act(async () => {
await result.current.handleSlashCommand('/clear');
});
expect(mockClearItems).toHaveBeenCalled();
expect(console.clear).toHaveBeenCalled();
});
});
describe('Initialization and Command Loading', () => { describe('Initialization and Command Loading', () => {
it('should initialize CommandService with all required loaders', async () => { it('should initialize CommandService with all required loaders', async () => {
await setupProcessorHook(); await setupProcessorHook();
@@ -947,36 +1005,37 @@ describe('useSlashCommandProcessor', () => {
describe('Slash Command Logging', () => { describe('Slash Command Logging', () => {
const mockCommandAction = vi.fn().mockResolvedValue({ type: 'handled' }); const mockCommandAction = vi.fn().mockResolvedValue({ type: 'handled' });
const loggingTestCommands: SlashCommand[] = [ let loggingTestCommands: SlashCommand[];
createTestCommand({
name: 'logtest',
action: vi
.fn()
.mockResolvedValue({ type: 'message', content: 'hello world' }),
}),
createTestCommand({
name: 'logwithsub',
subCommands: [
createTestCommand({
name: 'sub',
action: mockCommandAction,
}),
],
}),
createTestCommand({
name: 'fail',
action: vi.fn().mockRejectedValue(new Error('oh no!')),
}),
createTestCommand({
name: 'logalias',
altNames: ['la'],
action: mockCommandAction,
}),
];
beforeEach(() => { beforeEach(() => {
mockCommandAction.mockClear(); mockCommandAction.mockClear();
vi.mocked(logSlashCommand).mockClear(); vi.mocked(logSlashCommand).mockClear();
loggingTestCommands = [
createTestCommand({
name: 'logtest',
action: vi
.fn()
.mockResolvedValue({ type: 'message', content: 'hello world' }),
}),
createTestCommand({
name: 'logwithsub',
subCommands: [
createTestCommand({
name: 'sub',
action: mockCommandAction,
}),
],
}),
createTestCommand({
name: 'fail',
action: vi.fn().mockRejectedValue(new Error('oh no!')),
}),
createTestCommand({
name: 'logalias',
altNames: ['la'],
action: mockCommandAction,
}),
];
}); });
it.each([ it.each([
@@ -44,6 +44,7 @@ import {
type ExtensionUpdateStatus, type ExtensionUpdateStatus,
} from '../state/extensions.js'; } from '../state/extensions.js';
import { appEvents } from '../../utils/events.js'; import { appEvents } from '../../utils/events.js';
import { useAlternateBuffer } from './useAlternateBuffer.js';
interface SlashCommandProcessorActions { interface SlashCommandProcessorActions {
openAuthDialog: () => void; openAuthDialog: () => void;
@@ -81,6 +82,7 @@ export const useSlashCommandProcessor = (
const [commands, setCommands] = useState<readonly SlashCommand[] | undefined>( const [commands, setCommands] = useState<readonly SlashCommand[] | undefined>(
undefined, undefined,
); );
const alternateBuffer = useAlternateBuffer();
const [reloadTrigger, setReloadTrigger] = useState(0); const [reloadTrigger, setReloadTrigger] = useState(0);
const reloadCommands = useCallback(() => { const reloadCommands = useCallback(() => {
@@ -196,7 +198,9 @@ export const useSlashCommandProcessor = (
addItem, addItem,
clear: () => { clear: () => {
clearItems(); clearItems();
console.clear(); if (!alternateBuffer) {
console.clear();
}
refreshStatic(); refreshStatic();
}, },
loadHistory, loadHistory,
@@ -218,6 +222,7 @@ export const useSlashCommandProcessor = (
}, },
}), }),
[ [
alternateBuffer,
config, config,
settings, settings,
gitService, gitService,
+1 -1
View File
@@ -35,7 +35,7 @@ export interface MouseEvent {
ctrl: boolean; ctrl: boolean;
} }
export type MouseHandler = (event: MouseEvent) => void; export type MouseHandler = (event: MouseEvent) => void | boolean;
export function getMouseEventName( export function getMouseEventName(
buttonCode: number, buttonCode: number,
+2
View File
@@ -13,6 +13,7 @@ export enum AppEvent {
OauthDisplayMessage = 'oauth-display-message', OauthDisplayMessage = 'oauth-display-message',
Flicker = 'flicker', Flicker = 'flicker',
McpClientUpdate = 'mcp-client-update', McpClientUpdate = 'mcp-client-update',
SelectionWarning = 'selection-warning',
} }
export interface AppEvents extends ExtensionEvents { export interface AppEvents extends ExtensionEvents {
@@ -21,6 +22,7 @@ export interface AppEvents extends ExtensionEvents {
[AppEvent.OauthDisplayMessage]: string[]; [AppEvent.OauthDisplayMessage]: string[];
[AppEvent.Flicker]: never[]; [AppEvent.Flicker]: never[];
[AppEvent.McpClientUpdate]: Array<Map<string, McpClient> | never>; [AppEvent.McpClientUpdate]: Array<Map<string, McpClient> | never>;
[AppEvent.SelectionWarning]: never[];
} }
export const appEvents = new EventEmitter<AppEvents>(); export const appEvents = new EventEmitter<AppEvents>();