feat(cli) Scrollbar for input prompt (#21992)

This commit is contained in:
Jacob Richman
2026-04-03 15:10:04 -07:00
committed by GitHub
parent 893ae4d29a
commit d5a5995281
6 changed files with 439 additions and 196 deletions
+233 -155
View File
@@ -12,6 +12,10 @@ import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
import {
ScrollableList,
type ScrollableListRef,
} from './shared/ScrollableList.js';
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
import {
type TextBuffer,
@@ -95,6 +99,10 @@ export function isTerminalPasteTrusted(
return kittyProtocolSupported;
}
export type ScrollableItem =
| { type: 'visualLine'; lineText: string; absoluteVisualIdx: number }
| { type: 'ghostLine'; ghostLine: string; index: number };
export interface InputPromptProps {
buffer: TextBuffer;
onSubmit: (value: string) => void;
@@ -268,6 +276,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const innerBoxRef = useRef<DOMElement>(null);
const hasUserNavigatedSuggestions = useRef(false);
const listRef = useRef<ScrollableListRef<ScrollableItem>>(null);
const [reverseSearchActive, setReverseSearchActive] = useState(false);
const [commandSearchActive, setCommandSearchActive] = useState(false);
@@ -556,7 +565,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (isEmbeddedShellFocused) {
setEmbeddedShellFocused(false);
}
const visualRow = buffer.visualScrollRow + relY;
const currentScrollTop = Math.round(
listRef.current?.getScrollState().scrollTop ?? buffer.visualScrollRow,
);
const visualRow = currentScrollTop + relY;
buffer.moveToVisualPosition(visualRow, relX);
},
{ isActive: focus },
@@ -570,7 +582,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
(_event, relX, relY) => {
if (!isAlternateBuffer) return;
const visualLine = buffer.viewportVisualLines[relY];
const currentScrollTop = Math.round(
listRef.current?.getScrollState().scrollTop ?? buffer.visualScrollRow,
);
const visualLine = buffer.allVisualLines[currentScrollTop + relY];
if (!visualLine) return;
// Even if we click past the end of the line, we might want to collapse an expanded paste
@@ -578,10 +593,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const logicalPos = isPastEndOfLine
? null
: buffer.getLogicalPositionFromVisual(
buffer.visualScrollRow + relY,
relX,
);
: buffer.getLogicalPositionFromVisual(currentScrollTop + relY, relX);
// Check for paste placeholder (collapsed state)
if (logicalPos) {
@@ -603,7 +615,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// If we didn't click a placeholder to expand, check if we are inside or after
// an expanded paste region and collapse it.
const row = buffer.visualScrollRow + relY;
const visualRow = currentScrollTop + relY;
const mapEntry = buffer.visualToLogicalMap[visualRow];
const row = mapEntry ? mapEntry[0] : visualRow;
const expandedId = buffer.getExpandedPasteAtLine(row);
if (expandedId) {
buffer.togglePasteExpansion(
@@ -1350,10 +1364,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
priority: true,
});
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
const getGhostTextLines = useCallback(() => {
if (
@@ -1468,6 +1480,155 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const { inlineGhost, additionalLines } = getGhostTextLines();
const scrollableData = useMemo(() => {
const items: ScrollableItem[] = buffer.allVisualLines.map(
(lineText, index) => ({
type: 'visualLine',
lineText,
absoluteVisualIdx: index,
}),
);
additionalLines.forEach((ghostLine, index) => {
items.push({
type: 'ghostLine',
ghostLine,
index,
});
});
return items;
}, [buffer.allVisualLines, additionalLines]);
const renderItem = useCallback(
({ item }: { item: ScrollableItem; index: number }) => {
if (item.type === 'ghostLine') {
const padding = Math.max(0, inputWidth - stringWidth(item.ghostLine));
return (
<Box height={1}>
<Text color={theme.text.secondary}>
{item.ghostLine}
{' '.repeat(padding)}
</Text>
</Box>
);
}
const { lineText, absoluteVisualIdx } = item;
// console.log('renderItem called with:', lineText);
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
if (!mapEntry) return <Text> </Text>;
const isOnCursorLine =
focus && absoluteVisualIdx === cursorVisualRowAbsolute;
const renderedLine: React.ReactNode[] = [];
const [logicalLineIdx] = mapEntry;
const logicalLine = buffer.lines[logicalLineIdx] || '';
const transformations =
buffer.transformationsByLine[logicalLineIdx] ?? [];
const tokens = parseInputForHighlighting(
logicalLine,
logicalLineIdx,
transformations,
...(focus && buffer.cursor[0] === logicalLineIdx
? [buffer.cursor[1]]
: []),
);
const visualStartCol =
buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0;
const visualEndCol = visualStartCol + cpLen(lineText);
const segments = parseSegmentsFromTokens(
tokens,
visualStartCol,
visualEndCol,
);
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
let display = seg.text;
if (isOnCursorLine) {
const relCol = cursorVisualColAbsolute;
const segStart = charCount;
const segEnd = segStart + segLen;
if (relCol >= segStart && relCol < segEnd) {
const charToHighlight = cpSlice(
display,
relCol - segStart,
relCol - segStart + 1,
);
const highlighted = showCursor
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(display, 0, relCol - segStart) +
highlighted +
cpSlice(display, relCol - segStart + 1);
}
charCount = segEnd;
} else {
charCount += segLen;
}
const color =
seg.type === 'command' || seg.type === 'file' || seg.type === 'paste'
? theme.text.accent
: theme.text.primary;
renderedLine.push(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
});
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
if (
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText) &&
!currentLineGhost
) {
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') : ' '}
</Text>,
);
}
const showCursorBeforeGhost =
focus &&
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText) &&
currentLineGhost;
return (
<Box height={1}>
<Text
terminalCursorFocus={showCursor && isOnCursorLine}
terminalCursorPosition={cpIndexToOffset(
lineText,
cursorVisualColAbsolute,
)}
>
{renderedLine}
{showCursorBeforeGhost && (showCursor ? chalk.inverse(' ') : ' ')}
{currentLineGhost && (
<Text color={theme.text.secondary}>{currentLineGhost}</Text>
)}
</Text>
</Box>
);
},
[
buffer.visualToLogicalMap,
buffer.lines,
buffer.transformationsByLine,
buffer.cursor,
buffer.visualToTransformedMap,
focus,
cursorVisualRowAbsolute,
cursorVisualColAbsolute,
showCursor,
inlineGhost,
inputWidth,
],
);
const useBackgroundColor = config.getUseBackgroundColor();
const isLowColor = isLowColorDepth();
const terminalBg = theme.background.primary || 'black';
@@ -1485,6 +1646,46 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return false;
}, [useBackgroundColor, isLowColor, terminalBg]);
const prevCursorRef = useRef(buffer.visualCursor);
const prevTextRef = useRef(buffer.text);
// Effect to ensure cursor remains visible after interactions
useEffect(() => {
const cursorChanged = prevCursorRef.current !== buffer.visualCursor;
const textChanged = prevTextRef.current !== buffer.text;
prevCursorRef.current = buffer.visualCursor;
prevTextRef.current = buffer.text;
if (!cursorChanged && !textChanged) return;
if (!listRef.current || !focus) return;
const { scrollTop, innerHeight } = listRef.current.getScrollState();
if (innerHeight === 0) return;
const cursorVisualRow = buffer.visualCursor[0];
const actualScrollTop = Math.round(scrollTop);
// If cursor is out of the currently visible viewport...
if (
cursorVisualRow < actualScrollTop ||
cursorVisualRow >= actualScrollTop + innerHeight
) {
// Calculate minimal scroll to make it visible
let newScrollTop = actualScrollTop;
if (cursorVisualRow < actualScrollTop) {
newScrollTop = cursorVisualRow;
} else if (cursorVisualRow >= actualScrollTop + innerHeight) {
newScrollTop = cursorVisualRow - innerHeight + 1;
}
listRef.current.scrollToIndex({ index: newScrollTop });
}
}, [buffer.visualCursor, buffer.text, focus]);
const listBackgroundColor =
useLineFallback || !useBackgroundColor ? undefined : theme.background.input;
useEffect(() => {
if (onSuggestionsVisibilityChange) {
onSuggestionsVisibilityChange(shouldShowSuggestions);
@@ -1615,153 +1816,30 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
linesToRender
.map((lineText: string, visualIdxInRenderedSet: number) => {
const absoluteVisualIdx =
scrollVisualRow + visualIdxInRenderedSet;
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
if (!mapEntry) return null;
const cursorVisualRow =
cursorVisualRowAbsolute - scrollVisualRow;
const isOnCursorLine =
focus && visualIdxInRenderedSet === cursorVisualRow;
const renderedLine: React.ReactNode[] = [];
const [logicalLineIdx] = mapEntry;
const logicalLine = buffer.lines[logicalLineIdx] || '';
const transformations =
buffer.transformationsByLine[logicalLineIdx] ?? [];
const tokens = parseInputForHighlighting(
logicalLine,
logicalLineIdx,
transformations,
...(focus && buffer.cursor[0] === logicalLineIdx
? [buffer.cursor[1]]
: []),
);
const startColInTransformed =
buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0;
const visualStartCol = startColInTransformed;
const visualEndCol = visualStartCol + cpLen(lineText);
const segments = parseSegmentsFromTokens(
tokens,
visualStartCol,
visualEndCol,
);
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
let display = seg.text;
if (isOnCursorLine) {
const relativeVisualColForHighlight =
cursorVisualColAbsolute;
const segStart = charCount;
const segEnd = segStart + segLen;
if (
relativeVisualColForHighlight >= segStart &&
relativeVisualColForHighlight < segEnd
) {
const charToHighlight = cpSlice(
display,
relativeVisualColForHighlight - segStart,
relativeVisualColForHighlight - segStart + 1,
);
const highlighted = showCursor
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(
display,
0,
relativeVisualColForHighlight - segStart,
) +
highlighted +
cpSlice(
display,
relativeVisualColForHighlight - segStart + 1,
);
}
charCount = segEnd;
} else {
// Advance the running counter even when not on cursor line
charCount += segLen;
}
const color =
seg.type === 'command' ||
seg.type === 'file' ||
seg.type === 'paste'
? theme.text.accent
: theme.text.primary;
renderedLine.push(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
});
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
if (
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
if (!currentLineGhost) {
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') : ' '}
</Text>,
);
}
<Box
flexDirection="column"
height={Math.min(buffer.viewportHeight, scrollableData.length)}
width="100%"
>
<ScrollableList
ref={listRef}
hasFocus={focus}
data={scrollableData}
renderItem={renderItem}
estimatedItemHeight={() => 1}
keyExtractor={(item) =>
item.type === 'visualLine'
? `line-${item.absoluteVisualIdx}`
: `ghost-${item.index}`
}
const showCursorBeforeGhost =
focus &&
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText) &&
currentLineGhost;
return (
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
<Text
terminalCursorFocus={showCursor && isOnCursorLine}
terminalCursorPosition={cpIndexToOffset(
lineText,
cursorVisualColAbsolute,
)}
>
{renderedLine}
{showCursorBeforeGhost &&
(showCursor ? chalk.inverse(' ') : ' ')}
{currentLineGhost && (
<Text color={theme.text.secondary}>
{currentLineGhost}
</Text>
)}
</Text>
</Box>
);
})
.concat(
additionalLines.map((ghostLine, index) => {
const padding = Math.max(
0,
inputWidth - stringWidth(ghostLine),
);
return (
<Text
key={`ghost-line-${index}`}
color={theme.text.secondary}
>
{ghostLine}
{' '.repeat(padding)}
</Text>
);
}),
)
width="100%"
backgroundColor={listBackgroundColor}
containerHeight={Math.min(
buffer.viewportHeight,
scrollableData.length,
)}
/>
</Box>
)}
</Box>
</Box>