diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 7499a8c683..b13da27fa3 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -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 }], diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d2cf5081c7..075b311e01 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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: true, + description: + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).', + showInDialog: false, + }, }, }, diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index cb3229a2be..3f36b8149b 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1948,4 +1948,9 @@ 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: 'ausführlich', + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).': + 'Vollständige Tool-Ausgabe und Denkprozess im ausführlichen Modus anzeigen (mit Strg+O umschalten).', + 'Press Ctrl+O to show full tool output': + 'Strg+O für vollständige Tool-Ausgabe drücken', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 3178ea533d..83bdb98786 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1989,4 +1989,9 @@ 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', + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).': + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).', + 'Press Ctrl+O to show full tool output': + 'Press Ctrl+O to show full tool output', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index ac5f591116..93807fefc0 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1441,4 +1441,8 @@ export default { 'Rawモードが利用できません。インタラクティブターミナルで実行してください。', '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(↑ ↓ 矢印キーで移動、Enter で選択、Ctrl+C で終了)\n', + verbose: '詳細', + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).': + '詳細モードで完全なツール出力と思考を表示します(Ctrl+O で切り替え)。', + 'Press Ctrl+O to show full tool output': 'Ctrl+O で完全なツール出力を表示', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 993cd8d8c1..220ef4f9a3 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1938,4 +1938,9 @@ 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: 'detalhado', + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).': + 'Mostrar saída completa da ferramenta e raciocínio no modo detalhado (alternar com Ctrl+O).', + 'Press Ctrl+O to show full tool output': + 'Pressione Ctrl+O para exibir a saída completa da ferramenta', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index bb7e8968f7..66b5a50ac2 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1945,4 +1945,9 @@ export default { 'Raw-режим недоступен. Пожалуйста, запустите в интерактивном терминале.', '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(↑ ↓ стрелки для навигации, Enter для выбора, Ctrl+C для выхода)\n', + verbose: 'подробный', + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).': + 'Показывать полный вывод инструментов и процесс рассуждений в подробном режиме (переключить с помощью Ctrl+O).', + 'Press Ctrl+O to show full tool output': + 'Нажмите Ctrl+O для показа полного вывода инструментов', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index ad755b721e..d1af881e6c 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1795,4 +1795,8 @@ export default { '原始模式不可用。请在交互式终端中运行。', '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(使用 ↑ ↓ 箭头导航,Enter 选择,Ctrl+C 退出)\n', + verbose: '详细', + 'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).': + '详细模式下显示完整工具输出和思考过程(Ctrl+O 切换)。', + 'Press Ctrl+O to show full tool output': '按 Ctrl+O 查看详细工具调用结果', }; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 37dc325180..67746a933c 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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(false); + const [verboseMode, setVerboseMode] = useState( + settings.merged.ui?.verboseMode ?? true, + ); + const [frozenSnapshot, setFrozenSnapshot] = useState< + HistoryItemWithoutId[] | null + >(null); + const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); const ctrlCTimerRef = useRef(null); const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); @@ -977,6 +990,18 @@ export const AppContainer = (props: AppContainerProps) => { const [showEscapePrompt, setShowEscapePrompt] = useState(false); const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); + useEffect(() => { + // Clear frozen snapshot when streaming ends OR when entering confirmation + // state. During WaitingForConfirmation, the user needs to see the latest + // pending items (including the confirmation message) rather than a stale snapshot. + if ( + streamingState === StreamingState.Idle || + streamingState === StreamingState.WaitingForConfirmation + ) { + setFrozenSnapshot(null); + } + }, [streamingState]); + const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = useFolderTrust(settings, setIsTrustedFolder); const { @@ -1347,6 +1372,18 @@ 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(); + // Only freeze during the actual responding phase. WaitingForConfirmation + // must keep focus so the user can approve/cancel tool confirmation UI. + if (streamingState === StreamingState.Responding) { + setFrozenSnapshot([...pendingHistoryItems]); + } else { + setFrozenSnapshot(null); + } } }, [ @@ -1375,8 +1412,16 @@ export const AppContainer = (props: AppContainerProps) => { btwItem, setBtwItem, cancelBtw, - settings.merged.general?.debugKeystrokeLogging, + // `settings` is a stable LoadedSettings instance (not recreated on render). + // ESLint requires it here because the callback calls settings.setValue(). + // debugKeystrokeLogging is read at call time, so no stale closure risk. + settings, isAuthenticating, + verboseMode, + setVerboseMode, + setFrozenSnapshot, + pendingHistoryItems, + refreshStatic, ], ); @@ -1465,11 +1510,6 @@ export const AppContainer = (props: AppContainerProps) => { sessionStats, }); - const pendingHistoryItems = useMemo( - () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], - [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], - ); - const uiState: UIState = useMemo( () => ({ history: historyManager.history, @@ -1797,6 +1837,11 @@ export const AppContainer = (props: AppContainerProps) => { ], ); + const verboseModeValue = useMemo( + () => ({ verboseMode, frozenSnapshot }), + [verboseMode, frozenSnapshot], + ); + return ( @@ -1807,9 +1852,11 @@ export const AppContainer = (props: AppContainerProps) => { startupWarnings: props.startupWarnings || [], }} > - - - + + + + + diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index af81f6a5d7..0305d0320f 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -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: {t('verbose')}, + }); + } return ( = ({ ? 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 = ({ contentWidth={contentWidth} /> )} - {itemForDisplay.type === 'gemini_thought' && ( + {verboseMode && itemForDisplay.type === 'gemini_thought' && ( = ({ contentWidth={contentWidth} /> )} - {itemForDisplay.type === 'gemini_thought_content' && ( + {verboseMode && itemForDisplay.type === 'gemini_thought_content' && ( = ({ isFocused={isFocused} activeShellPtyId={activeShellPtyId} embeddedShellFocused={embeddedShellFocused} + isUserInitiated={itemForDisplay.isUserInitiated} /> )} {itemForDisplay.type === 'compression' && ( diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 53551c547c..e38e88e744 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -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 = () => { - {pendingHistoryItems.map((item, i) => ( - - ))} + {(frozenSnapshot ?? pendingHistoryItems).map((item, i) => { + const isFrozen = frozenSnapshot !== null; + return ( + + ); + })} diff --git a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap index e221961782..13017da601 100644 --- a/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/Footer.test.tsx.snap @@ -1,5 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`