Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
358 changes: 358 additions & 0 deletions docs/design/virtual-viewport/README.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions packages/cli/src/config/keyBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export enum Command {
// Suggestion expansion
EXPAND_SUGGESTION = 'expandSuggestion',
COLLAPSE_SUGGESTION = 'collapseSuggestion',

// Scroll commands
SCROLL_UP = 'scrollUp',
SCROLL_DOWN = 'scrollDown',
PAGE_UP = 'pageUp',
PAGE_DOWN = 'pageDown',
SCROLL_HOME = 'scrollHome',
SCROLL_END = 'scrollEnd',
}

/**
Expand Down Expand Up @@ -196,4 +204,12 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Suggestion expansion
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],

// Scroll commands
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
[Command.SCROLL_DOWN]: [{ key: 'down', shift: true }],
[Command.PAGE_UP]: [{ key: 'pageup' }],
[Command.PAGE_DOWN]: [{ key: 'pagedown' }],
[Command.SCROLL_HOME]: [{ key: 'home', ctrl: true }],
[Command.SCROLL_END]: [{ key: 'end', ctrl: true }],
};
10 changes: 10 additions & 0 deletions packages/cli/src/config/settingsSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,16 @@ describe('SettingsSchema', () => {
]);
});

it('should have useTerminalBuffer in ui settings', () => {
const useTerminalBuffer =
getSettingsSchema().ui.properties.useTerminalBuffer;
expect(useTerminalBuffer).toBeDefined();
expect(useTerminalBuffer.type).toBe('boolean');
expect(useTerminalBuffer.default).toBe(false);
expect(useTerminalBuffer.showInDialog).toBe(true);
expect(useTerminalBuffer.requiresRestart).toBe(false);
});

it('should infer Settings type correctly', () => {
// This test ensures that the Settings type is properly inferred from the schema
const settings: Settings = {
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,16 @@ const SETTINGS_SCHEMA = {
'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).',
showInDialog: true,
},
useTerminalBuffer: {
type: 'boolean',
label: 'Virtualized History (reduces flicker on long sessions)',
category: 'UI',
requiresRestart: false,
default: false,
description:
'Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Recommended if you see flicker, scroll-storm, or interface freeze on long sessions, after Ctrl+O, after Ctrl+E / Ctrl+F (expand), after window resize, or when alt-tabbing back. Scroll with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom). Does NOT use the host terminal scrollback while enabled.',
showInDialog: true,
},
shellOutputMaxLines: {
type: 'number',
label: 'Shell Output Max Lines',
Expand Down
23 changes: 19 additions & 4 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -576,17 +576,30 @@ export const AppContainer = (props: AppContainerProps) => {
setHistoryRemountKey((prev) => prev + 1);
}, []);

// In VP mode (ui.useTerminalBuffer) the React tree fully owns the visible
// region via ink 7 native overflow clipping, so writing clearTerminal /
// cursorTo+eraseDown to the host terminal is a wasted flash and corrupts
// the in-app scroll position. Skip the physical write and only bump the
// remount key — the VP path ignores the key (uses state-driven scroll
// reset), but the legacy `<Static>` path still needs it.
const useTerminalBuffer = settings.merged.ui?.useTerminalBuffer ?? false;
const refreshStatic = useCallback(() => {
stdout.write(ansiEscapes.clearTerminal);
if (!useTerminalBuffer) {
stdout.write(ansiEscapes.clearTerminal);
}
remountStaticHistory();
}, [remountStaticHistory, stdout]);
}, [useTerminalBuffer, remountStaticHistory, stdout]);

// Targeted repaint for resize events: move cursor to top-left and erase
// downward instead of a full clearTerminal, avoiding the full-screen flash.
// VP mode handles resize via ink's reflow + its own overflow clipping, so
// the physical write is unnecessary there too.
const repaintStaticViewport = useCallback(() => {
stdout.write(`${ansiEscapes.cursorTo(0, 0)}${ansiEscapes.eraseDown}`);
if (!useTerminalBuffer) {
stdout.write(`${ansiEscapes.cursorTo(0, 0)}${ansiEscapes.eraseDown}`);
}
remountStaticHistory();
}, [remountStaticHistory, stdout]);
}, [useTerminalBuffer, remountStaticHistory, stdout]);

// Keep the static header in sync with model changes without polling.
// Ink's <Static> output is append-only, so model changes must explicitly
Expand Down Expand Up @@ -2696,6 +2709,7 @@ export const AppContainer = (props: AppContainerProps) => {
currentModel,
contextFileNames,
availableTerminalHeight,
useTerminalBuffer,
mainAreaWidth,
staticAreaMaxItemHeight,
staticExtraHeight,
Expand Down Expand Up @@ -2813,6 +2827,7 @@ export const AppContainer = (props: AppContainerProps) => {
showAutoAcceptIndicator,
contextFileNames,
availableTerminalHeight,
useTerminalBuffer,
mainAreaWidth,
staticAreaMaxItemHeight,
staticExtraHeight,
Expand Down
197 changes: 194 additions & 3 deletions packages/cli/src/ui/components/MainContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const staticPropsSpy = vi.fn();
const staticItemsSpy = vi.fn();
const historyItemDisplayPropsSpy = vi.fn();
const appHeaderSpy = vi.fn();
const scrollableListPropsSpy = vi.fn();

vi.mock('ink', async () => {
const actual = await vi.importActual<typeof import('ink')>('ink');
Expand Down Expand Up @@ -54,9 +55,28 @@ vi.mock('./HistoryItemDisplay.js', () => ({
},
}));

vi.mock('./ShowMoreLines.js', () => ({
ShowMoreLines: () => <Text>SHOW_MORE</Text>,
}));
// Context-aware mock — `useOverflowState()` returns undefined when the
// component is not nested under <OverflowProvider>. We render different
// markers for the two outcomes so the VP-mode "ShowMoreLines reachable"
// test can distinguish between "mounted but disconnected from overflow
// state" and "mounted with a live overflow context".
vi.mock('./ShowMoreLines.js', async () => {
const { useOverflowState } = await vi.importActual<
typeof import('../contexts/OverflowContext.js')
>('../contexts/OverflowContext.js');
return {
ShowMoreLines: () => {
const overflow = useOverflowState();
// Non-overlapping markers so a `toContain('SHOW_MORE')` substring
// assertion can't accidentally match the disconnected case.
return (
<Text>
{overflow === undefined ? 'OVERFLOW_DISCONNECTED' : 'SHOW_MORE'}
</Text>
);
},
};
});

vi.mock('./Notifications.js', () => ({
Notifications: () => <Text>NOTIFICATIONS</Text>,
Expand All @@ -66,6 +86,31 @@ vi.mock('./DebugModeNotification.js', () => ({
DebugModeNotification: () => <Text>DEBUG_NOTIFICATION</Text>,
}));

vi.mock('./shared/ScrollableList.js', async () => {
const actual = await vi.importActual<
typeof import('./shared/ScrollableList.js')
>('./shared/ScrollableList.js');
return {
...actual,
ScrollableList: (props: {
data: Array<{ id: number }>;
renderItem: (info: { item: { id: number }; index: number }) => unknown;
}) => {
scrollableListPropsSpy(props);
// Drive renderItem once per item so historyItemDisplayPropsSpy fires —
// mirrors what the real VirtualizedList does for the visible window.
return (
<>
{props.data.map((item) => (
<Text key={item.id}>{`VP_ITEM:${item.id}`}</Text>
))}
{props.data.map((item, index) => props.renderItem({ item, index }))}
</>
);
},
};
});

const createUIState = (overrides: Partial<UIState> = {}): UIState =>
({
history: [],
Expand Down Expand Up @@ -457,6 +502,152 @@ describe('<MainContent />', () => {
expect(staticItemsSpy.mock.calls.at(-1)?.[0]).toHaveLength(53);
});

describe('virtual viewport path (ui.useTerminalBuffer)', () => {
it('renders ScrollableList and skips <Static> entirely when useTerminalBuffer is true', () => {
staticPropsSpy.mockClear();
scrollableListPropsSpy.mockClear();

const { lastFrame } = renderMainContent(
createUIState({
useTerminalBuffer: true,
history: [
{ id: 1, type: 'user', text: 'hello' },
{ id: 2, type: 'gemini', text: 'world' },
],
}),
);

expect(scrollableListPropsSpy).toHaveBeenCalled();
expect(staticPropsSpy).not.toHaveBeenCalled();
expect(lastFrame()).toContain('APP_HEADER:1.2.3');
// Items reach VP via renderItem
expect(lastFrame()).toMatch(/VP_ITEM:1[\s\S]*VP_ITEM:2/);
});

it('keeps ShowMoreLines reachable in VP mode (regression of OverflowProvider misplacement)', () => {
const { lastFrame } = renderMainContent(
createUIState({
useTerminalBuffer: true,
constrainHeight: true,
// Build pending content tall enough that ShowMoreLines would announce
// hidden lines if it sees the overflow context. We don't assert the
// hidden-line count here (depends on OverflowContext internals); the
// smoke check is that <ShowMoreLines> mounts at all, which the
// previous OverflowProvider-misplacement bug suppressed.
pendingHistoryItems: [
{
type: 'gemini',
text: Array.from({ length: 200 }, (_, i) => `line ${i}`).join(
'\n',
),
},
],
}),
);

// SHOW_MORE = live overflow context; OVERFLOW_DISCONNECTED = mounted
// but the OverflowProvider does not wrap it (the previous bug).
expect(lastFrame()).toContain('SHOW_MORE');
expect(lastFrame()).not.toContain('OVERFLOW_DISCONNECTED');
});

it('threads source-copy index offsets into renderItem for static history', () => {
historyItemDisplayPropsSpy.mockClear();

renderMainContent(
createUIState({
useTerminalBuffer: true,
history: [
{
id: 1,
type: 'gemini_content',
text: ['```mermaid', 'flowchart TD', ' A --> B', '```'].join(
'\n',
),
},
{
id: 2,
type: 'gemini_content',
text: ['```mermaid', 'flowchart TD', ' C --> D', '```'].join(
'\n',
),
},
],
}),
);

// Both items routed through renderItem; the SECOND one's offsets must
// include the mermaid block from item #1 — i.e. mermaidBlockCount > 0
// for the second call. This is the legacy contract; VP path was missing
// it until the audit follow-up.
const calls = historyItemDisplayPropsSpy.mock.calls.map((c) => c[0]);
const item2Call = calls.find((p) => p?.item?.id === 2);
expect(item2Call).toBeDefined();
expect(item2Call.sourceCopyIndexOffsets).toBeDefined();
});

it('reads pending-only UI state via refs (renderItem callback identity stable across activePtyId flips)', () => {
scrollableListPropsSpy.mockClear();

// History / pending / slashCommands arrays MUST be reused across the two
// renders — otherwise their new references invalidate
// `mergedHistory` / `allVirtualItems` / renderItem's own slashCommands
// dep and cascade independently of the activePtyId field we're testing.
// The test fixture defaults create fresh `[]` literals on each call;
// pin them to stable refs here to isolate the flip.
const stableHistory: UIState['history'] = [
{ id: 1, type: 'user', text: 'hello' },
];
const stablePending: UIState['pendingHistoryItems'] = [];
const stableSlashCommands: UIState['slashCommands'] = [];

// Render once without an active shell.
const { rerender } = renderMainContent(
createUIState({
useTerminalBuffer: true,
activePtyId: undefined,
history: stableHistory,
pendingHistoryItems: stablePending,
slashCommands: stableSlashCommands,
}),
);

const firstRenderItem =
scrollableListPropsSpy.mock.calls.at(-1)?.[0].renderItem;

// Flip activePtyId; identical re-render except this one streaming-state field.
rerender(
<AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}>
<CompactModeProvider value={{ compactMode: false }}>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider
value={createUIState({
useTerminalBuffer: true,
activePtyId: 'pty-xyz',
history: stableHistory,
pendingHistoryItems: stablePending,
slashCommands: stableSlashCommands,
})}
>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</CompactModeProvider>
</AppContext.Provider>,
);

const secondRenderItem =
scrollableListPropsSpy.mock.calls.at(-1)?.[0].renderItem;

// If activePtyId were still a useCallback dep, the identity would
// change here and static items would re-render on every shell tick.
// The ref-based read keeps identity stable.
expect(secondRenderItem).toBe(firstRenderItem);
});
});

it('does NOT reset progressive replay when only currentModel changes (PR #4119 regression guard)', async () => {
// Wenshao's review on PR #4119: if AppContainer splits the model-change
// wiring into two separate effects (setCurrentModel first, refreshStatic
Expand Down
Loading