fix(cli): robust fix for shell scroll-jacking and focus issues

This commit is contained in:
Keith Guerin
2026-03-01 01:13:27 -08:00
parent 9d028db351
commit 5a36725f22
4 changed files with 67 additions and 24 deletions

View File

@@ -26,7 +26,6 @@ export const StickyHeader: React.FC<StickyHeaderProps> = ({
containerRef,
}) => (
<Box
ref={containerRef}
sticky
minHeight={1}
flexShrink={0}
@@ -58,6 +57,7 @@ export const StickyHeader: React.FC<StickyHeaderProps> = ({
}
>
<Box
ref={containerRef}
borderStyle="round"
width={width}
borderColor={borderColor}

View File

@@ -107,9 +107,9 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
}
};
useMouseClick(headerRef, handleFocus, { isActive: !!isThisShellFocusable });
useMouseClick(headerRef, handleFocus, { isActive: true });
useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable });
useMouseClick(contentRef, handleFocus, { isActive: true });
const { shouldShowFocusHint } = useFocusHint(
isThisShellFocusable,

View File

@@ -15,12 +15,12 @@ import {
SHELL_FOCUS_HINT_DELAY_MS,
} from '../../constants.js';
import { theme } from '../../semantic-colors.js';
import {
import type {
CoreToolCallStatus,
type Config,
SHELL_TOOL_NAME,
isCompletedAskUserTool,
type ToolResultDisplay,
CoreToolCallStatus,
type ToolResultDisplay
} from '@google/gemini-cli-core';
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
@@ -44,14 +44,10 @@ export function isShellTool(name: string): boolean {
*/
export function isThisShellFocusable(
name: string,
status: CoreToolCallStatus,
config?: Config,
_status: CoreToolCallStatus,
_config?: Config,
): boolean {
return !!(
isShellTool(name) &&
status === CoreToolCallStatus.Executing &&
config?.getEnableInteractiveShell()
);
return isShellTool(name);
}
/**
@@ -59,14 +55,13 @@ export function isThisShellFocusable(
*/
export function isThisShellFocused(
name: string,
status: CoreToolCallStatus,
_status: CoreToolCallStatus,
ptyId?: number,
activeShellPtyId?: number | null,
embeddedShellFocused?: boolean,
): boolean {
return !!(
isShellTool(name) &&
status === CoreToolCallStatus.Executing &&
ptyId === activeShellPtyId &&
embeddedShellFocused
);

View File

@@ -5,12 +5,20 @@
*/
import type React from 'react';
import { useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
import { Box, ResizeObserver, type DOMElement } from 'ink';
import {
useState,
useRef,
useCallback,
useMemo,
useLayoutEffect,
useEffect,
} from 'react';
import { Box, ResizeObserver, getBoundingBox, type DOMElement } from 'ink';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { useMouse, type MouseEvent } from '../../hooks/useMouse.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
interface ScrollableProps {
@@ -115,13 +123,34 @@ export const Scrollable: React.FC<ScrollableProps> = ({
[scrollToBottom],
);
const [isHovered, setIsHovered] = useState(false);
useMouse(
(event: MouseEvent) => {
if (event.name === 'move' && viewportRef.current) {
const boundingBox = getBoundingBox(viewportRef.current);
if (boundingBox) {
const { x, y, width, height } = boundingBox;
const inside =
event.col >= x &&
event.col < x + width + 1 &&
event.row >= y &&
event.row < y + height;
if (inside !== isHovered) {
setIsHovered(inside);
}
}
}
return false;
},
{ isActive: true },
);
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
const scrollBy = useCallback(
(delta: number) => {
if (!hasFocus) {
return;
}
const { scrollHeight, innerHeight } = sizeRef.current;
const maxScroll = Math.max(0, scrollHeight - innerHeight);
const current = Math.min(getScrollTop(), maxScroll);
@@ -132,14 +161,26 @@ export const Scrollable: React.FC<ScrollableProps> = ({
setPendingScrollTop(next);
setScrollTop(next);
},
[getScrollTop, setPendingScrollTop, hasFocus],
[getScrollTop, setPendingScrollTop],
);
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
useAnimatedScrollbar(hasFocus, scrollBy);
// Flash scrollbar on hover for discoverability.
const wasHovered = useRef(isHovered);
useEffect(() => {
if (isHovered && !wasHovered.current && !hasFocus) {
flashScrollbar();
}
wasHovered.current = isHovered;
}, [isHovered, hasFocus, flashScrollbar]);
useKeypress(
(key: Key) => {
if (!hasFocus) {
return false;
}
const { scrollHeight, innerHeight } = sizeRef.current;
const scrollTop = getScrollTop();
const maxScroll = Math.max(0, scrollHeight - innerHeight);
@@ -178,13 +219,20 @@ export const Scrollable: React.FC<ScrollableProps> = ({
);
const getScrollState = useCallback(() => {
if (!hasFocus && !isHovered) {
return {
scrollTop: 0,
scrollHeight: 0,
innerHeight: 0,
};
}
const maxScroll = Math.max(0, size.scrollHeight - size.innerHeight);
return {
scrollTop: Math.min(getScrollTop(), maxScroll),
scrollHeight: size.scrollHeight,
innerHeight: size.innerHeight,
};
}, [getScrollTop, size.scrollHeight, size.innerHeight]);
}, [hasFocus, isHovered, getScrollTop, size.scrollHeight, size.innerHeight]);
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
@@ -209,11 +257,11 @@ export const Scrollable: React.FC<ScrollableProps> = ({
width={width ?? maxWidth}
height={height}
flexDirection="column"
overflowY={hasFocus ? 'scroll' : 'hidden'}
overflowY={hasFocus || isHovered ? 'scroll' : 'hidden'}
overflowX="hidden"
scrollTop={scrollTop}
flexGrow={flexGrow}
scrollbarThumbColor={hasFocus ? scrollbarColor : undefined}
scrollbarThumbColor={hasFocus || isHovered ? scrollbarColor : undefined}
>
{/*
This inner box is necessary to prevent the parent from shrinking