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

View File

@@ -11,6 +11,7 @@ import { vi, type Mock } from 'vitest';
import type React from 'react';
import { useStdin } from 'ink';
import { EventEmitter } from 'node:events';
import { appEvents, AppEvent } from '../../utils/events.js';
// Mock the 'ink' module to control stdin
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 {
isTTY = true;
setRawMode = vi.fn();
@@ -47,6 +60,7 @@ describe('MouseContext', () => {
wrapper = ({ children }: { children: React.ReactNode }) => (
<MouseProvider mouseEventsEnabled={true}>{children}</MouseProvider>
);
vi.mocked(appEvents.emit).mockClear();
});
afterEach(() => {
@@ -91,6 +105,34 @@ describe('MouseContext', () => {
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', () => {
it.each([
{

View File

@@ -15,6 +15,7 @@ import {
} from 'react';
import { ESC } from '../utils/input.js';
import { debugLogger } from '@google/gemini-cli-core';
import { appEvents, AppEvent } from '../../utils/events.js';
import {
isIncompleteMouseSequence,
parseMouseEvent,
@@ -89,8 +90,23 @@ export function MouseProvider({
let mouseBuffer = '';
const broadcast = (event: MouseEvent) => {
let handled = false;
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);
}
};

View File

@@ -16,12 +16,12 @@ import { Box, type DOMElement } from 'ink';
import type { MouseEvent } from '../hooks/useMouse.js';
// Mock useMouse hook
const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void>();
const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void | boolean>();
vi.mock('../hooks/useMouse.js', async () => {
// We need to import React dynamically because this factory runs before top-level imports
const React = await import('react');
return {
useMouse: (callback: (event: MouseEvent) => void) => {
useMouse: (callback: (event: MouseEvent) => void | boolean) => {
React.useEffect(() => {
mockUseMouseCallbacks.add(callback);
return () => {
@@ -81,6 +81,81 @@ describe('ScrollProvider', () => {
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 () => {
const scrollBy = vi.fn();
const scrollTo = vi.fn();

View File

@@ -146,15 +146,16 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
if (direction === 'up' && canScrollUp) {
pendingScrollsRef.current.set(candidate.id, pendingDelta + delta);
scheduleFlush();
return;
return true;
}
if (direction === 'down' && canScrollDown) {
pendingScrollsRef.current.set(candidate.id, pendingDelta + delta);
scheduleFlush();
return;
return true;
}
}
return false;
};
const handleLeftPress = (mouseEvent: MouseEvent) => {
@@ -238,7 +239,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
id: entry.id,
offset,
};
return;
return true;
}
}
@@ -250,21 +251,27 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
if (candidates.length > 0) {
// The first candidate is the innermost one.
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 state = dragStateRef.current;
if (!state.active || !state.id) return;
if (!state.active || !state.id) return false;
const entry = scrollablesRef.current.get(state.id);
if (!entry || !entry.ref.current) {
state.active = false;
return;
return false;
}
const boundingBox = getBoundingBox(entry.ref.current);
if (!boundingBox) return;
if (!boundingBox) return false;
const { y } = boundingBox;
const { scrollTop, scrollHeight, innerHeight } = entry.getScrollState();
@@ -276,7 +283,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
const maxScrollTop = scrollHeight - innerHeight;
const maxThumbY = innerHeight - thumbHeight;
if (maxThumbY <= 0) return;
if (maxThumbY <= 0) return false;
const relativeMouseY = mouseEvent.row - y;
// 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 {
entry.scrollBy(targetScrollTop - scrollTop);
}
return true;
};
const handleLeftRelease = () => {
@@ -304,22 +312,25 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
id: null,
offset: 0,
};
return true;
}
return false;
};
useMouse(
(event: MouseEvent) => {
if (event.name === 'scroll-up') {
handleScroll('up', event);
return handleScroll('up', event);
} else if (event.name === 'scroll-down') {
handleScroll('down', event);
return handleScroll('down', event);
} else if (event.name === 'left-press') {
handleLeftPress(event);
return handleLeftPress(event);
} else if (event.name === 'move') {
handleMove(event);
return handleMove(event);
} else if (event.name === 'left-release') {
handleLeftRelease();
return handleLeftRelease();
}
return false;
},
{ isActive: true },
);

View File

@@ -124,6 +124,7 @@ export interface UIState {
showDebugProfiler: boolean;
showFullTodos: boolean;
copyModeEnabled: boolean;
selectionWarning: boolean;
}
export const UIStateContext = createContext<UIState | null>(null);