alternate buffer support (#12471)

This commit is contained in:
Jacob Richman
2025-11-03 13:41:58 -08:00
committed by GitHub
parent 60973aacd9
commit 4fc9b1cde2
26 changed files with 1893 additions and 257 deletions
@@ -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();
+29 -2
View File
@@ -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>
+62 -36
View File
@@ -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
/**