Skip to content
Merged
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
6 changes: 6 additions & 0 deletions packages/cli/src/config/keyBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ export enum Command {
EXPAND_SUGGESTION = 'expandSuggestion',
COLLAPSE_SUGGESTION = 'collapseSuggestion',

// Thinking expansion
TOGGLE_THINKING_EXPANDED = 'toggleThinkingExpanded',

// Scroll commands
SCROLL_UP = 'scrollUp',
SCROLL_DOWN = 'scrollDown',
Expand Down Expand Up @@ -237,6 +240,9 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],

// Thinking expansion
[Command.TOGGLE_THINKING_EXPANDED]: [{ key: 't', meta: true }],

// Scroll commands
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
[Command.SCROLL_DOWN]: [{ key: 'down', shift: true }],
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1247,6 +1247,7 @@ describe('startInteractiveUI', () => {
expect(options).toEqual({
exitOnCtrlC: false,
isScreenReaderEnabled: false,
alternateScreen: false,
});

// Verify React element structure is valid (but don't deep dive into JSX internals)
Expand Down
7 changes: 3 additions & 4 deletions packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,7 @@ import {
type InitializationResult,
} from './core/initializer.js';
import { handleList as handleListExtensions } from './commands/extensions/list.js';
import {
initializeI18n,
resolveLanguageSetting,
} from './i18n/index.js';
import { initializeI18n, resolveLanguageSetting } from './i18n/index.js';
import { runNonInteractive } from './nonInteractiveCli.js';
import {
setupStartupWorktree,
Expand Down Expand Up @@ -367,6 +364,7 @@ export async function startInteractiveUI(
);
};

const useVP = settings.merged.ui?.useTerminalBuffer ?? false;
const instance = render(
process.env['DEBUG'] ? (
<React.StrictMode>
Expand All @@ -378,6 +376,7 @@ export async function startInteractiveUI(
{
exitOnCtrlC: false,
isScreenReaderEnabled: config.getScreenReader(),
alternateScreen: useVP,
Comment thread
chiga0 marked this conversation as resolved.
Comment thread
chiga0 marked this conversation as resolved.
},
);
// Records the moment Ink's `render()` call has returned, which is
Expand Down
70 changes: 63 additions & 7 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ import {
useVimModeActions,
} from './contexts/VimModeContext.js';
import { CompactModeProvider } from './contexts/CompactModeContext.js';
import { ThoughtExpandedProvider } from './contexts/ThoughtExpandedContext.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { calculatePromptWidths } from './components/InputPrompt.js';
import { useStdin, useStdout } from 'ink';
Expand Down Expand Up @@ -197,6 +198,11 @@ import {
type RenderMode,
} from './contexts/RenderModeContext.js';
import { TerminalOutputProvider } from './contexts/TerminalOutputContext.js';
import {
ThinkingViewerProvider,
type ThinkingViewerData,
} from './contexts/ThinkingViewerContext.js';
import { ThinkingViewer } from './components/ThinkingViewer.js';
import { useAgentViewState } from './contexts/AgentViewContext.js';
import {
useBackgroundTaskViewState,
Expand Down Expand Up @@ -473,6 +479,19 @@ export const AppContainer = (props: AppContainerProps) => {

const [userMessages, setUserMessages] = useState<string[]>([]);

// Thinking viewer overlay state
const [thinkingViewerData, setThinkingViewerData] =
useState<ThinkingViewerData | null>(null);
const openThinkingViewer = useCallback((data: ThinkingViewerData) => {
setThinkingViewerData(data);
}, []);
const closeThinkingViewer = useCallback(() => {
setThinkingViewerData(null);
}, []);

// Alt+T inline expansion toggle for thinking blocks
const [thoughtExpanded, setThoughtExpanded] = useState(false);

// Terminal and layout hooks
const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize();
const { stdin, setRawMode } = useStdin();
Expand Down Expand Up @@ -3101,6 +3120,23 @@ export const AppContainer = (props: AppContainerProps) => {
debugLogger.debug('[DEBUG] Keystroke:', JSON.stringify(key));
}

// ThinkingViewer owns all input while open.
// Ctrl+C / Ctrl+D close the viewer and fall through to quit/exit.
if (thinkingViewerData) {
if (keyMatchers[Command.QUIT](key) || keyMatchers[Command.EXIT](key)) {
closeThinkingViewer();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] After closeThinkingViewer(), execution falls through (no return) into the normal Ctrl+C quit handler below, which arms the "press Ctrl+C again to quit" timer. The result: first Ctrl+C closes the overlay AND silently starts the quit countdown. A reflexive second Ctrl+C — natural when dismissing an overlay — exits the app.

Consider adding return after closeThinkingViewer() so Ctrl+C only closes the viewer. Users who want to quit can press Ctrl+C again after the overlay is gone.

Suggested change
closeThinkingViewer();
if (thinkingViewerData) {
if (keyMatchers[Command.QUIT](key) || keyMatchers[Command.EXIT](key)) {
closeThinkingViewer();
return;
} else {
return;
}
}

— qwen3.7-max via Qwen Code /review

} else {
return;
}
}

// Alt+T: toggle inline expansion of thinking blocks.
if (keyMatchers[Command.TOGGLE_THINKING_EXPANDED](key)) {
setThoughtExpanded((prev) => !prev);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] refreshStatic() clears the terminal and bumps historyRemountKey, forcing <Static> to fully remount all items. In VP mode (useTerminalBuffer=true), the render path uses ScrollableList (a regular React component, not Ink's append-only <Static>), so the context change propagates naturally without any terminal clear or remount. The refreshStatic() call here is pure overhead in VP mode.

Suggested change
setThoughtExpanded((prev) => !prev);
if (keyMatchers[Command.TOGGLE_THINKING_EXPANDED](key)) {
setThoughtExpanded((prev) => !prev);
if (!useTerminalBuffer) {
refreshStatic();
}
return;
}

— qwen3.7-max via Qwen Code /review

refreshStatic();
return;
}

if (keyMatchers[Command.QUIT](key)) {
if (isAuthenticating) {
return;
Expand Down Expand Up @@ -3361,6 +3397,9 @@ export const AppContainer = (props: AppContainerProps) => {
handleDoubleEscRewind,
vimEnabled,
vimMode,
thinkingViewerData,
closeThinkingViewer,
setThoughtExpanded,
],
);

Expand Down Expand Up @@ -3900,6 +3939,11 @@ export const AppContainer = (props: AppContainerProps) => {
[renderMode, setRenderMode],
);

const thinkingViewerValue = useMemo(
() => ({ openThinkingViewer }),
[openThinkingViewer],
);

return (
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
Expand All @@ -3911,13 +3955,25 @@ export const AppContainer = (props: AppContainerProps) => {
}}
>
<CompactModeProvider value={compactModeValue}>
<RenderModeProvider value={renderModeValue}>
<TerminalOutputProvider value={writeRaw}>
<ShellFocusContext.Provider value={isFocused}>
<App />
</ShellFocusContext.Provider>
</TerminalOutputProvider>
</RenderModeProvider>
<ThoughtExpandedProvider value={thoughtExpanded}>
<RenderModeProvider value={renderModeValue}>
<TerminalOutputProvider value={writeRaw}>
<ThinkingViewerProvider value={thinkingViewerValue}>
<ShellFocusContext.Provider value={isFocused}>
{thinkingViewerData ? (
<ThinkingViewer
data={thinkingViewerData}
onClose={closeThinkingViewer}
useAlternateScreen={!useTerminalBuffer}
/>
) : (
<App />
)}
</ShellFocusContext.Provider>
</ThinkingViewerProvider>
</TerminalOutputProvider>
</RenderModeProvider>
</ThoughtExpandedProvider>
</CompactModeProvider>
</AppContext.Provider>
</ConfigContext.Provider>
Expand Down
48 changes: 48 additions & 0 deletions packages/cli/src/ui/components/AlternateScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import type { FC, ReactNode } from 'react';
import { useEffect } from 'react';
import { Box } from 'ink';
import { useTerminalOutput } from '../contexts/TerminalOutputContext.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';

const ENTER_ALT_SCREEN = '\x1b[?1049h';
const EXIT_ALT_SCREEN = '\x1b[?1049l';
const CLEAR_SCREEN = '\x1b[2J\x1b[H';
const HIDE_CURSOR = '\x1b[?25l';
const SHOW_CURSOR = '\x1b[?25h';

interface AlternateScreenProps {
children: ReactNode;
/** Skip escape writes when the root Ink renderer already owns the alt screen (VP mode). */
disabled?: boolean;
}

export const AlternateScreen: FC<AlternateScreenProps> = ({
children,
disabled,
}) => {
const writeRaw = useTerminalOutput();
const { rows } = useTerminalSize();

useEffect(() => {
if (disabled) return;
writeRaw(ENTER_ALT_SCREEN + CLEAR_SCREEN + HIDE_CURSOR);
Comment thread
chiga0 marked this conversation as resolved.
const onExit = () => writeRaw(SHOW_CURSOR + EXIT_ALT_SCREEN);
process.on('exit', onExit);
return () => {
process.removeListener('exit', onExit);
writeRaw(SHOW_CURSOR + EXIT_ALT_SCREEN);
};
}, [writeRaw, disabled]);

return (
<Box flexDirection="column" height={rows}>
{children}
</Box>
);
};
38 changes: 32 additions & 6 deletions packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,17 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { LoadedSettings } from '../../config/settings.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { CompactModeProvider } from '../contexts/CompactModeContext.js';
import { ThoughtExpandedProvider } from '../contexts/ThoughtExpandedContext.js';

// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
ToolGroupMessage: vi.fn(() => <div />),
}));

vi.mock('../hooks/useMouseEvents.js', () => ({
Comment thread
chiga0 marked this conversation as resolved.
useMouseEvents: vi.fn(),
}));

describe('<HistoryItemDisplay />', () => {
const mockConfig = {
getChatRecordingService: () => undefined,
Expand Down Expand Up @@ -337,7 +342,7 @@ describe('<HistoryItemDisplay />', () => {
expect(lastFrame()).toContain('●');
});

it('renders committed thinking text in full transcript mode', () => {
it('renders committed thinking collapsed by default', () => {
Comment thread
chiga0 marked this conversation as resolved.
const item: HistoryItem = {
id: 1,
type: 'gemini_thought',
Expand All @@ -353,10 +358,11 @@ describe('<HistoryItemDisplay />', () => {

const output = lastFrame() ?? '';
expect(output).toContain('Thought for');
expect(output).toContain('Inspecting the repository');
expect(output).toContain('alt+t to expand');
expect(output).not.toContain('Inspecting the repository');
});

it('renders committed thinking continuations in full transcript mode', () => {
it('renders committed thinking continuations hidden by default', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini_thought_content',
Expand All @@ -369,10 +375,10 @@ describe('<HistoryItemDisplay />', () => {
</CompactModeProvider>,
);

expect(lastFrame()).toContain('Continuing the reasoning');
expect(lastFrame()).not.toContain('Continuing the reasoning');
});

it('keeps committed thinking collapsed in compact mode', () => {
it('keeps committed thinking collapsed in compact mode too', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini_thought',
Expand All @@ -388,10 +394,30 @@ describe('<HistoryItemDisplay />', () => {

const output = lastFrame() ?? '';
expect(output).toContain('Thought for');
expect(output).toContain('ctrl+o to expand');
expect(output).toContain('alt+t to expand');
expect(output).not.toContain('Inspecting the repository');
});

it('renders committed thinking expanded when ThoughtExpandedProvider is true', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini_thought',
text: 'Inspecting the repository',
durationMs: 1200,
};

const { lastFrame } = renderWithProviders(
<ThoughtExpandedProvider value={true}>
<HistoryItemDisplay item={item} terminalWidth={100} isPending={false} />
</ThoughtExpandedProvider>,
);

const output = lastFrame() ?? '';
expect(output).toContain('Thought for');
expect(output).toContain('alt+t to collapse');
expect(output).toContain('Inspecting the repository');
});

it('keeps committed thinking continuations hidden in compact mode', () => {
const item: HistoryItem = {
id: 1,
Expand Down
Loading
Loading