From d2b8ff5debc59af4fb8aebad53065594ddfeb8b7 Mon Sep 17 00:00:00 2001 From: Pyush Sinha Date: Wed, 17 Sep 2025 13:17:50 -0700 Subject: [PATCH] fix: InputPrompt wrapped lines maintain highlighting, increase responsiveness in narrow cases (#7656) Co-authored-by: Jacob Richman Co-authored-by: Arya Gummadi --- packages/cli/src/ui/AppContainer.tsx | 13 +-- .../cli/src/ui/components/Composer.test.tsx | 5 + packages/cli/src/ui/components/Composer.tsx | 11 ++- .../src/ui/components/InputPrompt.test.tsx | 8 ++ .../cli/src/ui/components/InputPrompt.tsx | 91 ++++++++++++++----- .../src/ui/components/shared/text-buffer.ts | 18 +++- packages/cli/src/ui/utils/highlight.ts | 38 ++++++++ 7 files changed, 150 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 75b8b20db5..f0f9cf19fa 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -56,6 +56,7 @@ import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; +import { calculatePromptWidths } from './components/InputPrompt.js'; import { useStdin, useStdout } from 'ink'; import ansiEscapes from 'ansi-escapes'; import * as fs from 'node:fs'; @@ -227,12 +228,12 @@ export const AppContainer = (props: AppContainerProps) => { registerCleanup(consolePatcher.cleanup); }, [handleNewMessage, config]); - const widthFraction = 0.9; - const inputWidth = Math.max( - 20, - Math.floor(terminalWidth * widthFraction) - 3, - ); - const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0)); + // Derive widths for InputPrompt using shared helper + const { inputWidth, suggestionsWidth } = useMemo(() => { + const { inputWidth, suggestionsWidth } = + calculatePromptWidths(terminalWidth); + return { inputWidth, suggestionsWidth }; + }, [terminalWidth]); const mainAreaWidth = Math.floor(terminalWidth * 0.9); const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100); diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 2abb973b5b..c9b7dd0a52 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -50,6 +50,11 @@ vi.mock('./DetailedMessagesDisplay.js', () => ({ vi.mock('./InputPrompt.js', () => ({ InputPrompt: () => InputPrompt, + calculatePromptWidths: vi.fn(() => ({ + inputWidth: 80, + suggestionsWidth: 40, + containerWidth: 84, + })), })); vi.mock('./Footer.js', () => ({ diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 6ef5bbfb9d..4ca52c2e9d 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -5,12 +5,13 @@ */ import { Box, Text } from 'ink'; +import { useMemo } from 'react'; import { LoadingIndicator } from './LoadingIndicator.js'; import { ContextSummaryDisplay } from './ContextSummaryDisplay.js'; import { AutoAcceptIndicator } from './AutoAcceptIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; -import { InputPrompt } from './InputPrompt.js'; +import { InputPrompt, calculatePromptWidths } from './InputPrompt.js'; import { Footer, type FooterProps } from './Footer.js'; import { ShowMoreLines } from './ShowMoreLines.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; @@ -39,6 +40,12 @@ export const Composer = () => { const { contextFileNames, showAutoAcceptIndicator } = uiState; + // Use the container width of InputPrompt for width of DetailedMessagesDisplay + const { containerWidth } = useMemo( + () => calculatePromptWidths(uiState.terminalWidth), + [uiState.terminalWidth], + ); + // Build footer props from context values const footerProps: Omit = { model: config.getModel(), @@ -163,7 +170,7 @@ export const Composer = () => { maxHeight={ uiState.constrainHeight ? debugConsoleMaxHeight : undefined } - width={uiState.inputWidth} + width={containerWidth} /> diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 109c6a6f9d..52bd3b8fd6 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -113,6 +113,7 @@ describe('InputPrompt', () => { mockBuffer.cursor = [0, newText.length]; mockBuffer.viewportVisualLines = [newText]; mockBuffer.allVisualLines = [newText]; + mockBuffer.visualToLogicalMap = [[0, 0]]; }), replaceRangeByOffset: vi.fn(), viewportVisualLines: [''], @@ -138,6 +139,7 @@ describe('InputPrompt', () => { replaceRange: vi.fn(), deleteWordLeft: vi.fn(), deleteWordRight: vi.fn(), + visualToLogicalMap: [[0, 0]], } as unknown as TextBuffer; mockShellHistory = { @@ -1344,6 +1346,12 @@ describe('InputPrompt', () => { mockBuffer.viewportVisualLines = text.split('\n'); mockBuffer.allVisualLines = text.split('\n'); mockBuffer.visualCursor = [2, 5]; // cursor at the end of "world" + // Provide a visual-to-logical mapping for each visual line + mockBuffer.visualToLogicalMap = [ + [0, 0], // 'hello' starts at col 0 of logical line 0 + [1, 0], // '' (blank) is logical line 1, col 0 + [2, 0], // 'world' is logical line 2, col 0 + ]; const { stdout, unmount } = renderWithProviders( , diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index fd01bde032..b56d7212c6 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -24,7 +24,10 @@ import { keyMatchers, Command } from '../keyMatchers.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { Config } from '@google/gemini-cli-core'; import { ApprovalMode } from '@google/gemini-cli-core'; -import { parseInputForHighlighting } from '../utils/highlight.js'; +import { + parseInputForHighlighting, + buildSegmentsForVisualSlice, +} from '../utils/highlight.js'; import { clipboardHasImage, saveClipboardImage, @@ -52,6 +55,31 @@ export interface InputPromptProps { isShellFocused?: boolean; } +// The input content, input container, and input suggestions list may have different widths +export const calculatePromptWidths = (terminalWidth: number) => { + const widthFraction = 0.9; + const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2) + const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! ' + const MIN_CONTENT_WIDTH = 2; + + const innerContentWidth = + Math.floor(terminalWidth * widthFraction) - + FRAME_PADDING_AND_BORDER - + PROMPT_PREFIX_WIDTH; + + const inputWidth = Math.max(MIN_CONTENT_WIDTH, innerContentWidth); + const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH; + const containerWidth = inputWidth + FRAME_OVERHEAD; + const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0)); + + return { + inputWidth, + containerWidth, + suggestionsWidth, + frameOverhead: FRAME_OVERHEAD, + } as const; +}; + export const InputPrompt: React.FC = ({ buffer, onSubmit, @@ -854,64 +882,79 @@ export const InputPrompt: React.FC = ({ ) : ( linesToRender .map((lineText, visualIdxInRenderedSet) => { - const tokens = parseInputForHighlighting( - lineText, - visualIdxInRenderedSet, - ); + const absoluteVisualIdx = + scrollVisualRow + visualIdxInRenderedSet; + const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx]; const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow; const isOnCursorLine = focus && visualIdxInRenderedSet === cursorVisualRow; const renderedLine: React.ReactNode[] = []; - let charCount = 0; - tokens.forEach((token, tokenIdx) => { - let display = token.text; + const [logicalLineIdx, logicalStartCol] = mapEntry; + const logicalLine = buffer.lines[logicalLineIdx] || ''; + const tokens = parseInputForHighlighting( + logicalLine, + logicalLineIdx, + ); + + const visualStart = logicalStartCol; + const visualEnd = logicalStartCol + cpLen(lineText); + const segments = buildSegmentsForVisualSlice( + tokens, + visualStart, + visualEnd, + ); + + let charCount = 0; + segments.forEach((seg, segIdx) => { + const segLen = cpLen(seg.text); + let display = seg.text; + if (isOnCursorLine) { const relativeVisualColForHighlight = cursorVisualColAbsolute; - const tokenStart = charCount; - const tokenEnd = tokenStart + cpLen(token.text); - + const segStart = charCount; + const segEnd = segStart + segLen; if ( - relativeVisualColForHighlight >= tokenStart && - relativeVisualColForHighlight < tokenEnd + relativeVisualColForHighlight >= segStart && + relativeVisualColForHighlight < segEnd ) { const charToHighlight = cpSlice( - token.text, - relativeVisualColForHighlight - tokenStart, - relativeVisualColForHighlight - tokenStart + 1, + seg.text, + relativeVisualColForHighlight - segStart, + relativeVisualColForHighlight - segStart + 1, ); const highlighted = chalk.inverse(charToHighlight); display = cpSlice( - token.text, + seg.text, 0, - relativeVisualColForHighlight - tokenStart, + relativeVisualColForHighlight - segStart, ) + highlighted + cpSlice( - token.text, - relativeVisualColForHighlight - tokenStart + 1, + seg.text, + relativeVisualColForHighlight - segStart + 1, ); } - charCount = tokenEnd; + charCount = segEnd; } const color = - token.type === 'command' || token.type === 'file' + seg.type === 'command' || seg.type === 'file' ? theme.text.accent : theme.text.primary; renderedLine.push( - + {display} , ); }); - const currentLineGhost = isOnCursorLine ? inlineGhost : ''; + const currentLineGhost = isOnCursorLine ? inlineGhost : ''; if ( isOnCursorLine && cursorVisualColAbsolute === cpLen(lineText) diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index a18f4c9c82..2799c36665 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -1568,7 +1568,7 @@ export function useTextBuffer({ [visualLayout, cursorRow, cursorCol], ); - const { visualLines } = visualLayout; + const { visualLines, visualToLogicalMap } = visualLayout; const [visualScrollRow, setVisualScrollRow] = useState(0); @@ -1588,6 +1588,8 @@ export function useTextBuffer({ // Update visual scroll (vertical) useEffect(() => { const { height } = viewport; + const totalVisualLines = visualLines.length; + const maxScrollStart = Math.max(0, totalVisualLines - height); let newVisualScrollRow = visualScrollRow; if (visualCursor[0] < visualScrollRow) { @@ -1595,10 +1597,15 @@ export function useTextBuffer({ } else if (visualCursor[0] >= visualScrollRow + height) { newVisualScrollRow = visualCursor[0] - height + 1; } + + // When the number of visual lines shrinks (e.g., after widening the viewport), + // ensure scroll never starts beyond the last valid start so we can render a full window. + newVisualScrollRow = clamp(newVisualScrollRow, 0, maxScrollStart); + if (newVisualScrollRow !== visualScrollRow) { setVisualScrollRow(newVisualScrollRow); } - }, [visualCursor, visualScrollRow, viewport]); + }, [visualCursor, visualScrollRow, viewport, visualLines.length]); const insert = useCallback( (ch: string, { paste = false }: { paste?: boolean } = {}): void => { @@ -1988,6 +1995,7 @@ export function useTextBuffer({ viewportVisualLines: renderedVisualLines, visualCursor, visualScrollRow, + visualToLogicalMap, setText, insert, @@ -2063,6 +2071,12 @@ export interface TextBuffer { viewportVisualLines: string[]; // The subset of visual lines to be rendered based on visualScrollRow and viewport.height visualCursor: [number, number]; // Visual cursor [row, col] relative to the start of all visualLines visualScrollRow: number; // Scroll position for visual lines (index of the first visible visual line) + /** + * For each visual line (by absolute index in allVisualLines) provides a tuple + * [logicalLineIndex, startColInLogical] that maps where that visual line + * begins within the logical buffer. Indices are code-point based. + */ + visualToLogicalMap: Array<[number, number]>; // Actions diff --git a/packages/cli/src/ui/utils/highlight.ts b/packages/cli/src/ui/utils/highlight.ts index f1a6e4656d..1e48b36f13 100644 --- a/packages/cli/src/ui/utils/highlight.ts +++ b/packages/cli/src/ui/utils/highlight.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { cpLen, cpSlice } from './textUtils.js'; + export type HighlightToken = { text: string; type: 'default' | 'command' | 'file'; @@ -63,3 +65,39 @@ export function parseInputForHighlighting( return tokens; } + +export function buildSegmentsForVisualSlice( + tokens: readonly HighlightToken[], + sliceStart: number, + sliceEnd: number, +): readonly HighlightToken[] { + if (sliceStart >= sliceEnd) return []; + + const segments: HighlightToken[] = []; + let tokenCpStart = 0; + + for (const token of tokens) { + const tokenLen = cpLen(token.text); + const tokenStart = tokenCpStart; + const tokenEnd = tokenStart + tokenLen; + + const overlapStart = Math.max(tokenStart, sliceStart); + const overlapEnd = Math.min(tokenEnd, sliceEnd); + if (overlapStart < overlapEnd) { + const sliceStartInToken = overlapStart - tokenStart; + const sliceEndInToken = overlapEnd - tokenStart; + const rawSlice = cpSlice(token.text, sliceStartInToken, sliceEndInToken); + + const last = segments[segments.length - 1]; + if (last && last.type === token.type) { + last.text += rawSlice; + } else { + segments.push({ type: token.type, text: rawSlice }); + } + } + + tokenCpStart += tokenLen; + } + + return segments; +}