feat: to #2767, support verbose and compact mode swither with ctrl-o

This commit is contained in:
秦奇 committed 2026-03-31 19:00:13 +08:00
1 parent 1b1a029fd7
commit b9c17d13ff
17 files changed
+166 -45

No files matched your search

+2
View File
@@ -51,6 +51,7 @@ export enum Command {
EXIT = 'exit',
SHOW_MORE_LINES = 'showMoreLines',
RETRY_LAST = 'retryLast',
TOGGLE_VERBOSE_MODE = 'toggleVerboseMode',
// Shell commands
REVERSE_SEARCH = 'reverseSearch',
@@ -172,6 +173,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.EXIT]: [{ key: 'd', ctrl: true }],
[Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
[Command.RETRY_LAST]: [{ key: 'y', ctrl: true }],
[Command.TOGGLE_VERBOSE_MODE]: [{ key: 'o', ctrl: true }],
// Shell commands
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
+10
View File
@@ -540,6 +540,16 @@ const SETTINGS_SCHEMA = {
description: 'The last time the feedback dialog was shown.',
showInDialog: false,
},
verboseMode: {
type: 'boolean',
label: 'Verbose Mode',
category: 'UI',
requiresRestart: false,
default: false,
description:
'Show full tool output and thinking in verbose mode (toggle with ctrl+o).',
showInDialog: false,
},
},
},
+1
View File
@@ -1948,4 +1948,5 @@ export default {
'Raw-Modus nicht verfügbar. Bitte in einem interaktiven Terminal ausführen.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ Pfeiltasten zum Navigieren, Enter zum Auswählen, Strg+C zum Beenden)\n',
verbose: 'verbose',
};
+1
View File
@@ -1989,4 +1989,5 @@ export default {
'Raw mode not available. Please run in an interactive terminal.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n',
verbose: 'verbose',
};
+1
View File
@@ -1441,4 +1441,5 @@ export default {
'Rawモードが利用できません。インタラクティブターミナルで実行してください。',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ 矢印キーで移動、Enter で選択、Ctrl+C で終了)\n',
verbose: 'verbose',
};
+1
View File
@@ -1938,4 +1938,5 @@ export default {
'Modo raw não disponível. Execute em um terminal interativo.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(Use ↑ ↓ para navegar, Enter para selecionar, Ctrl+C para sair)\n',
verbose: 'verbose',
};
+1
View File
@@ -1945,4 +1945,5 @@ export default {
'Raw-режим недоступен. Пожалуйста, запустите в интерактивном терминале.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ стрелки для навигации, Enter для выбора, Ctrl+C для выхода)\n',
verbose: 'verbose',
};
+1
View File
@@ -1795,4 +1795,5 @@ export default {
'原始模式不可用。请在交互式终端中运行。',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(使用 ↑ ↓ 箭头导航,Enter 选择,Ctrl+C 退出)\n',
verbose: '详细模式',
};
+40 -9
View File
@@ -60,6 +60,7 @@ import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
import { useResumeCommand } from './hooks/useResumeCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useVimMode } from './contexts/VimModeContext.js';
import { VerboseModeProvider } from './contexts/VerboseModeContext.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { calculatePromptWidths } from './components/InputPrompt.js';
import { useStdin, useStdout } from 'ink';
@@ -792,6 +793,11 @@ export const AppContainer = (props: AppContainerProps) => {
handleWelcomeBackClose,
} = useWelcomeBack(config, handleFinalSubmit, buffer, settings.merged);
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
cancelHandlerRef.current = useCallback(() => {
const pendingHistoryItems = [
...pendingSlashCommandHistoryItems,
@@ -963,6 +969,13 @@ export const AppContainer = (props: AppContainerProps) => {
const [showToolDescriptions, setShowToolDescriptions] =
useState<boolean>(false);
const [verboseMode, setVerboseMode] = useState<boolean>(
settings.merged.ui?.verboseMode ?? false,
);
const [frozenSnapshot, setFrozenSnapshot] = useState<
HistoryItemWithoutId[] | null
>(null);
const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
@@ -977,6 +990,12 @@ export const AppContainer = (props: AppContainerProps) => {
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
useEffect(() => {
if (streamingState === StreamingState.Idle) {
setFrozenSnapshot(null);
}
}, [streamingState]);
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
useFolderTrust(settings, setIsTrustedFolder);
const {
@@ -1347,6 +1366,16 @@ export const AppContainer = (props: AppContainerProps) => {
if (activePtyId || embeddedShellFocused) {
setEmbeddedShellFocused((prev) => !prev);
}
} else if (keyMatchers[Command.TOGGLE_VERBOSE_MODE](key)) {
const newValue = !verboseMode;
setVerboseMode(newValue);
void settings.setValue(SettingScope.User, 'ui.verboseMode', newValue);
refreshStatic();
if (newValue && streamingState !== StreamingState.Idle) {
setFrozenSnapshot([...pendingHistoryItems]);
} else {
setFrozenSnapshot(null);
}
}
},
[
@@ -1375,8 +1404,13 @@ export const AppContainer = (props: AppContainerProps) => {
btwItem,
setBtwItem,
cancelBtw,
settings.merged.general?.debugKeystrokeLogging,
settings,
isAuthenticating,
verboseMode,
setVerboseMode,
setFrozenSnapshot,
pendingHistoryItems,
refreshStatic,
],
);
@@ -1465,11 +1499,6 @@ export const AppContainer = (props: AppContainerProps) => {
sessionStats,
});
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
const uiState: UIState = useMemo(
() => ({
history: historyManager.history,
@@ -1807,9 +1836,11 @@ export const AppContainer = (props: AppContainerProps) => {
startupWarnings: props.startupWarnings || [],
}}
>
<ShellFocusContext.Provider value={isFocused}>
<App />
</ShellFocusContext.Provider>
<VerboseModeProvider value={{ verboseMode, frozenSnapshot }}>
<ShellFocusContext.Provider value={isFocused}>
<App />
</ShellFocusContext.Provider>
</VerboseModeProvider>
</AppContext.Provider>
</ConfigContext.Provider>
</UIActionsContext.Provider>
@@ -16,6 +16,7 @@ import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useVerboseMode } from '../contexts/VerboseModeContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
@@ -23,6 +24,7 @@ export const Footer: React.FC = () => {
const uiState = useUIState();
const config = useConfig();
const { vimEnabled, vimMode } = useVimMode();
const { verboseMode } = useVerboseMode();
const { promptTokenCount, showAutoAcceptIndicator } = {
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
@@ -93,6 +95,12 @@ export const Footer: React.FC = () => {
),
});
}
if (verboseMode) {
rightItems.push({
key: 'verbose',
node: <Text color={theme.text.accent}>{t('verbose')}</Text>,
});
}
return (
<Box
justifyContent="space-between"
@@ -43,6 +43,7 @@ import { ContextUsage } from './views/ContextUsage.js';
import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js';
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
import { BtwMessage } from './messages/BtwMessage.js';
import { useVerboseMode } from '../contexts/VerboseModeContext.js';
interface HistoryItemDisplayProps {
item: HistoryItem;
@@ -74,6 +75,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
? 0
: 1;
const { verboseMode } = useVerboseMode();
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
const contentWidth = terminalWidth - 4;
const boxWidth = mainAreaWidth || contentWidth;
@@ -113,7 +115,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
contentWidth={contentWidth}
/>
)}
{itemForDisplay.type === 'gemini_thought' && (
{verboseMode && itemForDisplay.type === 'gemini_thought' && (
<ThinkMessage
text={itemForDisplay.text}
isPending={isPending}
@@ -123,7 +125,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
contentWidth={contentWidth}
/>
)}
{itemForDisplay.type === 'gemini_thought_content' && (
{verboseMode && itemForDisplay.type === 'gemini_thought_content' && (
<ThinkMessageContent
text={itemForDisplay.text}
isPending={isPending}
+22 -15
View File
@@ -13,6 +13,7 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
import { DebugModeNotification } from './DebugModeNotification.js';
import { useVerboseMode } from '../contexts/VerboseModeContext.js';
// Limit Gemini messages to a very high number of lines to mitigate performance
// issues in the worst case if we somehow get an enormous response from Gemini.
@@ -23,6 +24,7 @@ const MAX_GEMINI_MESSAGE_LINES = 65536;
export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
const { frozenSnapshot } = useVerboseMode();
const {
pendingHistoryItems,
terminalWidth,
@@ -57,21 +59,26 @@ export const MainContent = () => {
</Static>
<OverflowProvider>
<Box flexDirection="column">
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={terminalWidth}
mainAreaWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
))}
{(frozenSnapshot ?? pendingHistoryItems).map((item, i) => {
const isFrozen = frozenSnapshot !== null;
return (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={terminalWidth}
mainAreaWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={isFrozen ? false : !uiState.isEditorDialogOpen}
activeShellPtyId={isFrozen ? undefined : uiState.activePtyId}
embeddedShellFocused={
isFrozen ? false : uiState.embeddedShellFocused
}
/>
);
})}
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>
@@ -12,6 +12,7 @@ import { StreamingState, ToolCallStatus } from '../../types.js';
import { Text } from 'ink';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import { SettingsContext } from '../../contexts/SettingsContext.js';
import { VerboseModeProvider } from '../../contexts/VerboseModeContext.js';
import type {
AnsiOutput,
AnsiOutputDisplay,
@@ -101,18 +102,21 @@ const mockSettings: LoadedSettings = {
},
} as LoadedSettings;
// Helper to render with context
// Helper to render with context (verbose=true by default to show tool output)
const renderWithContext = (
ui: React.ReactElement,
streamingState: StreamingState,
verboseMode = true,
) => {
const contextValue: StreamingState = streamingState;
return render(
<SettingsContext.Provider value={mockSettings}>
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>
</SettingsContext.Provider>,
<VerboseModeProvider value={{ verboseMode, frozenSnapshot: null }}>
<SettingsContext.Provider value={mockSettings}>
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>
</SettingsContext.Provider>
</VerboseModeProvider>,
);
};
@@ -143,6 +147,18 @@ describe('<ToolMessage />', () => {
expect(output).toContain('MockMarkdown:Test result');
});
it('hides result output in compact mode (verboseMode=false)', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} />,
StreamingState.Idle,
false, // compact mode
);
const output = lastFrame();
expect(output).toContain('✓'); // status indicator still visible
expect(output).toContain('test-tool'); // tool name still visible
expect(output).not.toContain('MockMarkdown:Test result'); // result hidden
});
describe('ToolStatusIndicator rendering', () => {
it('shows ✓ for Success status', () => {
const { lastFrame } = renderWithContext(
@@ -33,6 +33,7 @@ import {
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../../config/settings.js';
import { useVerboseMode } from '../../contexts/VerboseModeContext.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
@@ -324,6 +325,10 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
// Use the custom hook to determine the display type
const displayRenderer = useResultDisplayRenderer(resultDisplay);
const { verboseMode } = useVerboseMode();
const effectiveDisplayRenderer = verboseMode
? displayRenderer
: { type: 'none' as const };
return (
<Box paddingX={1} paddingY={0} flexDirection="column">
@@ -344,44 +349,44 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
)}
{emphasis === 'high' && <TrailingIndicator />}
</Box>
{displayRenderer.type !== 'none' && (
{effectiveDisplayRenderer.type !== 'none' && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}>
<Box flexDirection="column">
{displayRenderer.type === 'todo' && (
<TodoResultRenderer data={displayRenderer.data} />
{effectiveDisplayRenderer.type === 'todo' && (
<TodoResultRenderer data={effectiveDisplayRenderer.data} />
)}
{displayRenderer.type === 'plan' && (
{effectiveDisplayRenderer.type === 'plan' && (
<PlanResultRenderer
data={displayRenderer.data}
data={effectiveDisplayRenderer.data}
availableHeight={availableHeight}
childWidth={innerWidth}
/>
)}
{displayRenderer.type === 'task' && config && (
{effectiveDisplayRenderer.type === 'task' && config && (
<SubagentExecutionRenderer
data={displayRenderer.data}
data={effectiveDisplayRenderer.data}
availableHeight={availableHeight}
childWidth={innerWidth}
config={config}
/>
)}
{displayRenderer.type === 'diff' && (
{effectiveDisplayRenderer.type === 'diff' && (
<DiffResultRenderer
data={displayRenderer.data}
data={effectiveDisplayRenderer.data}
availableHeight={availableHeight}
childWidth={innerWidth}
settings={settings}
/>
)}
{displayRenderer.type === 'ansi' && (
{effectiveDisplayRenderer.type === 'ansi' && (
<AnsiOutputText
data={displayRenderer.data}
data={effectiveDisplayRenderer.data}
availableTerminalHeight={availableHeight}
/>
)}
{displayRenderer.type === 'string' && (
{effectiveDisplayRenderer.type === 'string' && (
<StringResultRenderer
data={displayRenderer.data}
data={effectiveDisplayRenderer.data}
renderAsMarkdown={renderOutputAsMarkdown}
availableHeight={availableHeight}
childWidth={innerWidth}
@@ -0,0 +1,23 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
import type { HistoryItemWithoutId } from '../types.js';
interface VerboseModeContextType {
verboseMode: boolean;
frozenSnapshot: HistoryItemWithoutId[] | null;
}
const VerboseModeContext = createContext<VerboseModeContextType>({
verboseMode: false,
frozenSnapshot: null,
});
export const useVerboseMode = (): VerboseModeContextType =>
useContext(VerboseModeContext);
export const VerboseModeProvider = VerboseModeContext.Provider;
+6
View File
@@ -60,6 +60,7 @@ describe('keyMatchers', () => {
[Command.EXIT]: (key: Key) => key.ctrl && key.name === 'd',
[Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's',
[Command.RETRY_LAST]: (key: Key) => key.ctrl && key.name === 'y',
[Command.TOGGLE_VERBOSE_MODE]: (key: Key) => key.ctrl && key.name === 'o',
[Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r',
[Command.SUBMIT_REVERSE_SEARCH]: (key: Key) =>
key.name === 'return' && !key.ctrl,
@@ -258,6 +259,11 @@ describe('keyMatchers', () => {
positive: [createKey('y', { ctrl: true })],
negative: [createKey('y'), createKey('r', { ctrl: true })],
},
{
command: Command.TOGGLE_VERBOSE_MODE,
positive: [createKey('o', { ctrl: true })],
negative: [createKey('o'), createKey('p', { ctrl: true })],
},
// Shell commands
{
@@ -194,6 +194,11 @@
"description": "The last time the feedback dialog was shown.",
"type": "number",
"default": 0
},
"verboseMode": {
"description": "Show full tool output and thinking in verbose mode (toggle with ctrl+o).",
"type": "boolean",
"default": false
}
}
},