feat(cli): truncate shell output in UI history and improve active shell display (#17438)

This commit is contained in:
Jarrod Whelan
2026-02-08 00:09:48 -08:00
committed by GitHub
parent 31522045cd
commit 4a48d7cf93
34 changed files with 1553 additions and 579 deletions

View File

@@ -117,4 +117,91 @@ describe('<Scrollable />', () => {
});
expect(capturedEntry.getScrollState().scrollTop).toBe(1);
});
describe('keypress handling', () => {
it.each([
{
name: 'scrolls down when overflow exists and not at bottom',
initialScrollTop: 0,
scrollHeight: 10,
keySequence: '\u001B[1;2B', // Shift+Down
expectedScrollTop: 1,
},
{
name: 'scrolls up when overflow exists and not at top',
initialScrollTop: 2,
scrollHeight: 10,
keySequence: '\u001B[1;2A', // Shift+Up
expectedScrollTop: 1,
},
{
name: 'does not scroll up when at top (allows event to bubble)',
initialScrollTop: 0,
scrollHeight: 10,
keySequence: '\u001B[1;2A', // Shift+Up
expectedScrollTop: 0,
},
{
name: 'does not scroll down when at bottom (allows event to bubble)',
initialScrollTop: 5, // maxScroll = 10 - 5 = 5
scrollHeight: 10,
keySequence: '\u001B[1;2B', // Shift+Down
expectedScrollTop: 5,
},
{
name: 'does not scroll when content fits (allows event to bubble)',
initialScrollTop: 0,
scrollHeight: 5, // Same as innerHeight (5)
keySequence: '\u001B[1;2B', // Shift+Down
expectedScrollTop: 0,
},
])(
'$name',
async ({
initialScrollTop,
scrollHeight,
keySequence,
expectedScrollTop,
}) => {
// Dynamically import ink to mock getScrollHeight
const ink = await import('ink');
vi.mocked(ink.getScrollHeight).mockReturnValue(scrollHeight);
let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined;
vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation(
(entry, isActive) => {
if (isActive) {
capturedEntry = entry as ScrollProviderModule.ScrollableEntry;
}
},
);
const { stdin } = renderWithProviders(
<Scrollable hasFocus={true} height={5}>
<Text>Content</Text>
</Scrollable>,
);
// Ensure initial state using existing scrollBy method
act(() => {
// Reset to top first, then scroll to desired start position
capturedEntry!.scrollBy(-100);
if (initialScrollTop > 0) {
capturedEntry!.scrollBy(initialScrollTop);
}
});
expect(capturedEntry!.getScrollState().scrollTop).toBe(
initialScrollTop,
);
act(() => {
stdin.write(keySequence);
});
expect(capturedEntry!.getScrollState().scrollTop).toBe(
expectedScrollTop,
);
},
);
});
});

View File

@@ -17,6 +17,7 @@ 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 { keyMatchers, Command } from '../../keyMatchers.js';
interface ScrollableProps {
children?: React.ReactNode;
@@ -103,14 +104,38 @@ export const Scrollable: React.FC<ScrollableProps> = ({
useKeypress(
(key: Key) => {
if (key.shift) {
if (key.name === 'up') {
scrollByWithAnimation(-1);
const { scrollHeight, innerHeight } = sizeRef.current;
const scrollTop = getScrollTop();
const maxScroll = Math.max(0, scrollHeight - innerHeight);
// Only capture scroll-up events if there's room;
// otherwise allow events to bubble.
if (scrollTop > 0) {
if (keyMatchers[Command.PAGE_UP](key)) {
scrollByWithAnimation(-innerHeight);
return true;
}
if (key.name === 'down') {
scrollByWithAnimation(1);
if (keyMatchers[Command.SCROLL_UP](key)) {
scrollByWithAnimation(-1);
return true;
}
}
// Only capture scroll-down events if there's room;
// otherwise allow events to bubble.
if (scrollTop < maxScroll) {
if (keyMatchers[Command.PAGE_DOWN](key)) {
scrollByWithAnimation(innerHeight);
return true;
}
if (keyMatchers[Command.SCROLL_DOWN](key)) {
scrollByWithAnimation(1);
return true;
}
}
// bubble keypress
return false;
},
{ isActive: hasFocus },
);
@@ -137,7 +162,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
[getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
);
useScrollable(scrollableEntry, hasFocus && ref.current !== null);
useScrollable(scrollableEntry, true);
return (
<Box

View File

@@ -186,9 +186,11 @@ function ScrollableList<T>(
if (keyMatchers[Command.SCROLL_UP](key)) {
stopSmoothScroll();
scrollByWithAnimation(-1);
return true;
} else if (keyMatchers[Command.SCROLL_DOWN](key)) {
stopSmoothScroll();
scrollByWithAnimation(1);
return true;
} else if (
keyMatchers[Command.PAGE_UP](key) ||
keyMatchers[Command.PAGE_DOWN](key)
@@ -200,11 +202,15 @@ function ScrollableList<T>(
: scrollState.scrollTop;
const innerHeight = scrollState.innerHeight;
smoothScrollTo(current + direction * innerHeight);
return true;
} else if (keyMatchers[Command.SCROLL_HOME](key)) {
smoothScrollTo(0);
return true;
} else if (keyMatchers[Command.SCROLL_END](key)) {
smoothScrollTo(SCROLL_TO_ITEM_END);
return true;
}
return false;
},
{ isActive: hasFocus },
);
@@ -229,7 +235,7 @@ function ScrollableList<T>(
],
);
useScrollable(scrollableEntry, hasFocus);
useScrollable(scrollableEntry, true);
return (
<Box