Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
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.
Resolved
Comment thread
chiga0 marked this conversation as resolved.
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;
Comment thread
chiga0 marked this conversation as resolved.
Resolved
}

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.
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.
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.
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