mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-28 22:14:52 -07:00
alternate buffer support (#12471)
This commit is contained in:
@@ -130,6 +130,7 @@ describe('InputPrompt', () => {
|
||||
moveToOffset: vi.fn((offset: number) => {
|
||||
mockBuffer.cursor = [0, offset];
|
||||
}),
|
||||
moveToVisualPosition: vi.fn(),
|
||||
killLineRight: vi.fn(),
|
||||
killLineLeft: vi.fn(),
|
||||
openInExternalEditor: vi.fn(),
|
||||
@@ -1590,28 +1591,42 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('resets reverse search state on Escape', async () => {
|
||||
const { stdin, stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
it.each([
|
||||
{ name: 'standard', kittyProtocolEnabled: false, escapeSequence: '\x1B' },
|
||||
{
|
||||
name: 'kitty',
|
||||
kittyProtocolEnabled: true,
|
||||
escapeSequence: '\u001b[27u',
|
||||
},
|
||||
])(
|
||||
'resets reverse search state on Escape ($name)',
|
||||
async ({ kittyProtocolEnabled, escapeSequence }) => {
|
||||
const { stdin, stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ kittyProtocolEnabled },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x12');
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('\x1B');
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('\u001b[27u'); // Press kitty escape key
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('\x12');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).not.toContain('(r:)');
|
||||
expect(stdout.lastFrame()).not.toContain('echo hello');
|
||||
});
|
||||
// Wait for reverse search to be active
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('(r:)');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write(escapeSequence);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).not.toContain('(r:)');
|
||||
expect(stdout.lastFrame()).not.toContain('echo hello');
|
||||
});
|
||||
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('completes the highlighted entry on Tab and exits reverse-search', async () => {
|
||||
// Mock the reverse search completion
|
||||
@@ -1936,6 +1951,77 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mouse interaction', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'first line, first char',
|
||||
relX: 0,
|
||||
relY: 0,
|
||||
mouseCol: 5,
|
||||
mouseRow: 2,
|
||||
},
|
||||
{
|
||||
name: 'first line, middle char',
|
||||
relX: 6,
|
||||
relY: 0,
|
||||
mouseCol: 11,
|
||||
mouseRow: 2,
|
||||
},
|
||||
{
|
||||
name: 'second line, first char',
|
||||
relX: 0,
|
||||
relY: 1,
|
||||
mouseCol: 5,
|
||||
mouseRow: 3,
|
||||
},
|
||||
{
|
||||
name: 'second line, end char',
|
||||
relX: 5,
|
||||
relY: 1,
|
||||
mouseCol: 10,
|
||||
mouseRow: 3,
|
||||
},
|
||||
])(
|
||||
'should move cursor on mouse click - $name',
|
||||
async ({ relX, relY, mouseCol, mouseRow }) => {
|
||||
props.buffer.text = 'hello world\nsecond line';
|
||||
props.buffer.lines = ['hello world', 'second line'];
|
||||
props.buffer.viewportVisualLines = ['hello world', 'second line'];
|
||||
props.buffer.visualToLogicalMap = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
];
|
||||
props.buffer.visualCursor = [0, 11];
|
||||
props.buffer.visualScrollRow = 0;
|
||||
|
||||
const { stdin, stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ mouseEventsEnabled: true },
|
||||
);
|
||||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('hello world');
|
||||
});
|
||||
|
||||
// Simulate left mouse press at calculated coordinates.
|
||||
// Assumes inner box is at x=4, y=1 based on border(1)+padding(1)+prompt(2) and border-top(1).
|
||||
await act(async () => {
|
||||
stdin.write(`\x1b[<0;${mouseCol};${mouseRow}M`);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.buffer.moveToVisualPosition).toHaveBeenCalledWith(
|
||||
relY,
|
||||
relX,
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('queued message editing', () => {
|
||||
it('should load all queued messages when up arrow is pressed with empty input', async () => {
|
||||
const mockPopAllMessages = vi.fn();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Box, Text, getBoundingBox, type DOMElement } from 'ink';
|
||||
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
@@ -40,6 +40,7 @@ import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
|
||||
|
||||
/**
|
||||
* Returns if the terminal can be trusted to handle paste events atomically
|
||||
@@ -127,6 +128,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
number | null
|
||||
>(null);
|
||||
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const innerBoxRef = useRef<DOMElement>(null);
|
||||
|
||||
const [dirs, setDirs] = useState<readonly string[]>(
|
||||
config.getWorkspaceContext().getDirectories(),
|
||||
@@ -356,6 +358,31 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
}, [buffer, config]);
|
||||
|
||||
const handleMouse = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (event.name === 'left-press' && innerBoxRef.current) {
|
||||
const { x, y, width, height } = getBoundingBox(innerBoxRef.current);
|
||||
// Terminal mouse events are 1-based, Ink layout is 0-based.
|
||||
const mouseX = event.col - 1;
|
||||
const mouseY = event.row - 1;
|
||||
if (
|
||||
mouseX >= x &&
|
||||
mouseX < x + width &&
|
||||
mouseY >= y &&
|
||||
mouseY < y + height
|
||||
) {
|
||||
const relX = mouseX - x;
|
||||
const relY = mouseY - y;
|
||||
const visualRow = buffer.visualScrollRow + relY;
|
||||
buffer.moveToVisualPosition(visualRow, relX);
|
||||
}
|
||||
}
|
||||
},
|
||||
[buffer],
|
||||
);
|
||||
|
||||
useMouse(handleMouse, { isActive: focus && !isEmbeddedShellFocused });
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key) => {
|
||||
// TODO(jacobr): this special case is likely not needed anymore.
|
||||
@@ -972,7 +999,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
'>'
|
||||
)}{' '}
|
||||
</Text>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
|
||||
{buffer.text.length === 0 && placeholder ? (
|
||||
showCursor ? (
|
||||
<Text>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
// Limit Gemini messages to a very high number of lines to mitigate performance
|
||||
// issues in the worst case if we somehow get an enormous response from Gemini.
|
||||
@@ -21,6 +22,9 @@ const MAX_GEMINI_MESSAGE_LINES = 65536;
|
||||
export const MainContent = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const settings = useSettings();
|
||||
const useAlternateBuffer = settings.merged.ui?.useAlternateBuffer ?? true;
|
||||
|
||||
const {
|
||||
pendingHistoryItems,
|
||||
mainAreaWidth,
|
||||
@@ -28,46 +32,68 @@ export const MainContent = () => {
|
||||
availableTerminalHeight,
|
||||
} = uiState;
|
||||
|
||||
const historyItems = [
|
||||
<AppHeader key="app-header" version={version} />,
|
||||
...uiState.history.map((h) => (
|
||||
<HistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={staticAreaMaxItemHeight}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={h.id}
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
/>
|
||||
)),
|
||||
];
|
||||
|
||||
const pendingItems = (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column" width={mainAreaWidth}>
|
||||
{pendingHistoryItems.map((item, i) => (
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
/>
|
||||
))}
|
||||
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
);
|
||||
|
||||
if (useAlternateBuffer) {
|
||||
// Placeholder alternate buffer implementation using a scrollable box that
|
||||
// is always scrolled to the bottom. In follow up PRs we will switch this
|
||||
// to a proper alternate buffer implementation.
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
overflowY="scroll"
|
||||
scrollTop={Number.MAX_SAFE_INTEGER}
|
||||
maxHeight={availableTerminalHeight}
|
||||
>
|
||||
<Box flexDirection="column" flexShrink={0}>
|
||||
{historyItems}
|
||||
{pendingItems}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Static
|
||||
key={uiState.historyRemountKey}
|
||||
items={[
|
||||
<AppHeader key="app-header" version={version} />,
|
||||
...uiState.history.map((h) => (
|
||||
<HistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={staticAreaMaxItemHeight}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={h.id}
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
/>
|
||||
)),
|
||||
]}
|
||||
>
|
||||
<Static key={uiState.historyRemountKey} items={historyItems}>
|
||||
{(item) => item}
|
||||
</Static>
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column" width={mainAreaWidth}>
|
||||
{pendingHistoryItems.map((item, i) => (
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
/>
|
||||
))}
|
||||
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
{pendingItems}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2016,6 +2016,36 @@ export function useTextBuffer({
|
||||
dispatch({ type: 'move_to_offset', payload: { offset } });
|
||||
}, []);
|
||||
|
||||
const moveToVisualPosition = useCallback(
|
||||
(visRow: number, visCol: number): void => {
|
||||
const { visualLines, visualToLogicalMap } = visualLayout;
|
||||
// Clamp visRow to valid range
|
||||
const clampedVisRow = Math.max(
|
||||
0,
|
||||
Math.min(visRow, visualLines.length - 1),
|
||||
);
|
||||
const visualLine = visualLines[clampedVisRow] || '';
|
||||
// Clamp visCol to the length of the visual line
|
||||
const clampedVisCol = Math.max(0, Math.min(visCol, cpLen(visualLine)));
|
||||
|
||||
if (visualToLogicalMap[clampedVisRow]) {
|
||||
const [logRow, logStartCol] = visualToLogicalMap[clampedVisRow];
|
||||
const newCursorRow = logRow;
|
||||
const newCursorCol = logStartCol + clampedVisCol;
|
||||
|
||||
dispatch({
|
||||
type: 'set_cursor',
|
||||
payload: {
|
||||
cursorRow: newCursorRow,
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: clampedVisCol,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[visualLayout],
|
||||
);
|
||||
|
||||
const returnValue: TextBuffer = useMemo(
|
||||
() => ({
|
||||
lines,
|
||||
@@ -2041,6 +2071,7 @@ export function useTextBuffer({
|
||||
replaceRange,
|
||||
replaceRangeByOffset,
|
||||
moveToOffset,
|
||||
moveToVisualPosition,
|
||||
deleteWordLeft,
|
||||
deleteWordRight,
|
||||
|
||||
@@ -2104,6 +2135,7 @@ export function useTextBuffer({
|
||||
replaceRange,
|
||||
replaceRangeByOffset,
|
||||
moveToOffset,
|
||||
moveToVisualPosition,
|
||||
deleteWordLeft,
|
||||
deleteWordRight,
|
||||
killLineRight,
|
||||
@@ -2265,6 +2297,7 @@ export interface TextBuffer {
|
||||
replacementText: string,
|
||||
) => void;
|
||||
moveToOffset(offset: number): void;
|
||||
moveToVisualPosition(visualRow: number, visualCol: number): void;
|
||||
|
||||
// Vim-specific operations
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user