From b9c17d13ffa8949b7a5e49ccc500e1b0509b974b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 31 Mar 2026 19:00:13 +0800 Subject: [PATCH 01/12] feat: to #2767, support verbose and compact mode swither with ctrl-o --- packages/cli/src/config/keyBindings.ts | 2 + packages/cli/src/config/settingsSchema.ts | 10 ++++ packages/cli/src/i18n/locales/de.js | 1 + packages/cli/src/i18n/locales/en.js | 1 + packages/cli/src/i18n/locales/ja.js | 1 + packages/cli/src/i18n/locales/pt.js | 1 + packages/cli/src/i18n/locales/ru.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + packages/cli/src/ui/AppContainer.tsx | 49 +++++++++++++++---- packages/cli/src/ui/components/Footer.tsx | 8 +++ .../src/ui/components/HistoryItemDisplay.tsx | 6 ++- .../cli/src/ui/components/MainContent.tsx | 37 ++++++++------ .../components/messages/ToolMessage.test.tsx | 28 ++++++++--- .../ui/components/messages/ToolMessage.tsx | 31 +++++++----- .../src/ui/contexts/VerboseModeContext.tsx | 23 +++++++++ packages/cli/src/ui/keyMatchers.test.ts | 6 +++ .../schemas/settings.schema.json | 5 ++ 17 files changed, 166 insertions(+), 45 deletions(-) create mode 100644 packages/cli/src/ui/contexts/VerboseModeContext.tsx diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 7499a8c68..b13da27fa 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 d2cf5081c..e6a964260 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: false, + 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 cb3229a2b..aaf814ee1 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -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', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 3178ea533..544dfb45c 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -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', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index ac5f59111..4b4bb53fb 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -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', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 993cd8d8c..928f198a2 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -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', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index bb7e8968f..ee0cfe69b 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -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', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index ad755b721..56b747dcf 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1795,4 +1795,5 @@ export default { '原始模式不可用。请在交互式终端中运行。', '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(使用 ↑ ↓ 箭头导航,Enter 选择,Ctrl+C 退出)\n', + verbose: '详细模式', }; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 37dc32518..b4b3a4dd9 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 ?? false, + ); + const [frozenSnapshot, setFrozenSnapshot] = useState< + HistoryItemWithoutId[] | null + >(null); + const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); const ctrlCTimerRef = useRef(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 || [], }} > - - - + + + + + diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index af81f6a5d..0305d0320 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' && ( { 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/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index e5f846601..fc624448f 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -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( - - - {ui} - - , + + + + {ui} + + + , ); }; @@ -143,6 +147,18 @@ describe('', () => { expect(output).toContain('MockMarkdown:Test result'); }); + it('hides result output in compact mode (verboseMode=false)', () => { + const { lastFrame } = renderWithContext( + , + 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( diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 3da9f0edd..68bb2b458 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -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 = ({ // 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 ( @@ -344,44 +349,44 @@ export const ToolMessage: React.FC = ({ )} {emphasis === 'high' && } - {displayRenderer.type !== 'none' && ( + {effectiveDisplayRenderer.type !== 'none' && ( - {displayRenderer.type === 'todo' && ( - + {effectiveDisplayRenderer.type === 'todo' && ( + )} - {displayRenderer.type === 'plan' && ( + {effectiveDisplayRenderer.type === 'plan' && ( )} - {displayRenderer.type === 'task' && config && ( + {effectiveDisplayRenderer.type === 'task' && config && ( )} - {displayRenderer.type === 'diff' && ( + {effectiveDisplayRenderer.type === 'diff' && ( )} - {displayRenderer.type === 'ansi' && ( + {effectiveDisplayRenderer.type === 'ansi' && ( )} - {displayRenderer.type === 'string' && ( + {effectiveDisplayRenderer.type === 'string' && ( ({ + verboseMode: false, + frozenSnapshot: null, +}); + +export const useVerboseMode = (): VerboseModeContextType => + useContext(VerboseModeContext); + +export const VerboseModeProvider = VerboseModeContext.Provider; diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 8961f9ff7..856c300c3 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -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 { diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index c7f53048e..4d6f1edc3 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -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 } } }, From 0eaa5e45614fd8067f776d0150ba6bbfcc7c3eb7 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 3 Apr 2026 23:02:25 +0800 Subject: [PATCH 02/12] fix(core): coerce stringified JSON values for anyOf/oneOf schemas (#2839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some LLMs serialize array/object values as JSON strings when the schema uses anyOf/oneOf with mixed types (e.g., Python's `list[str] | None`). The model returns '["url"]' (a string) instead of ["url"] (an array), causing Ajv to reject otherwise valid input. Add fixStringifiedJsonValues() coercion — same pattern as the existing fixBooleanValues() — that parses stringified arrays/objects back to their intended type when the schema expects a non-string type. --- .../core/src/utils/schemaValidator.test.ts | 149 ++++++++++++++++++ packages/core/src/utils/schemaValidator.ts | 89 +++++++++++ 2 files changed, 238 insertions(+) diff --git a/packages/core/src/utils/schemaValidator.test.ts b/packages/core/src/utils/schemaValidator.test.ts index e882b983b..c7a6d16f0 100644 --- a/packages/core/src/utils/schemaValidator.test.ts +++ b/packages/core/src/utils/schemaValidator.test.ts @@ -210,6 +210,132 @@ describe('SchemaValidator', () => { }); }); + describe('stringified JSON value coercion', () => { + it('should coerce stringified array for anyOf [array, null]', () => { + const schema = { + type: 'object', + properties: { + urls: { + anyOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'null' }, + ], + default: null, + }, + }, + }; + const params = { urls: '["https://example.com"]' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + expect(params.urls).toEqual(['https://example.com']); + }); + + it('should coerce stringified object for anyOf [object, null]', () => { + const schema = { + type: 'object', + properties: { + config: { + anyOf: [ + { + type: 'object', + properties: { key: { type: 'string' } }, + }, + { type: 'null' }, + ], + }, + }, + }; + const params = { config: '{"key":"value"}' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + expect(params.config).toEqual({ key: 'value' }); + }); + + it('should coerce stringified array for oneOf [array, null]', () => { + const schema = { + type: 'object', + properties: { + items: { + oneOf: [ + { type: 'array', items: { type: 'integer' } }, + { type: 'null' }, + ], + }, + }, + }; + const params = { items: '[1, 2, 3]' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + expect(params.items).toEqual([1, 2, 3]); + }); + + it('should not coerce when schema accepts string type', () => { + const schema = { + type: 'object', + properties: { + data: { + anyOf: [ + { type: 'string' }, + { type: 'array', items: { type: 'string' } }, + ], + }, + }, + }; + const params = { data: '["hello"]' }; + expect(SchemaValidator.validate(schema, params)).toBeNull(); + // Value should remain a string since string is accepted + expect(params.data).toBe('["hello"]'); + }); + + it('should not coerce invalid JSON strings', () => { + const schema = { + type: 'object', + properties: { + urls: { + anyOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'null' }, + ], + }, + }, + }; + const params = { urls: '[not valid json' }; + expect(SchemaValidator.validate(schema, params)).not.toBeNull(); + }); + + it('should not coerce strings that do not look like JSON', () => { + const schema = { + type: 'object', + properties: { + urls: { + anyOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'null' }, + ], + }, + }, + required: ['urls'], + }; + const params = { urls: 'hello world' }; + expect(SchemaValidator.validate(schema, params)).not.toBeNull(); + }); + + it('should handle stringified array with plain type (no anyOf)', () => { + // Should NOT coerce when there is no anyOf/oneOf — the schema just + // says type: array, and a string value is simply invalid. + const schema = { + type: 'object', + properties: { + urls: { type: 'array', items: { type: 'string' } }, + }, + required: ['urls'], + }; + const params = { urls: '["https://example.com"]' }; + // No anyOf/oneOf, so fixStringifiedJsonValues won't have types to check + // against — but getAcceptedTypes reads plain 'type' too, so it should + // still coerce since 'string' is not in the accepted types. + expect(SchemaValidator.validate(schema, params)).toBeNull(); + expect(params.urls).toEqual(['https://example.com']); + }); + }); + describe('JSON Schema version support', () => { it('should support JSON Schema draft-2020-12', () => { const schema = { @@ -280,6 +406,29 @@ describe('SchemaValidator', () => { expect(SchemaValidator.validate(schema, params)).toBeNull(); }); + it('should handle anyOf union types with draft-2020-12', () => { + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + urls: { + anyOf: [ + { type: 'array', items: { type: 'string' } }, + { type: 'null' }, + ], + default: null, + }, + }, + }; + expect( + SchemaValidator.validate(schema, { + urls: ['https://example.com'], + }), + ).toBeNull(); + expect(SchemaValidator.validate(schema, { urls: null })).toBeNull(); + expect(SchemaValidator.validate(schema, {})).toBeNull(); + }); + it('should gracefully handle unsupported schema versions', () => { // draft-2019-09 is not supported by Ajv by default const schema = { diff --git a/packages/core/src/utils/schemaValidator.ts b/packages/core/src/utils/schemaValidator.ts index 396b5346e..c261164d8 100644 --- a/packages/core/src/utils/schemaValidator.ts +++ b/packages/core/src/utils/schemaValidator.ts @@ -102,6 +102,13 @@ export class SchemaValidator { if (!valid && validate.errors) { // Coerce string boolean values ("true"/"false") to actual booleans fixBooleanValues(data as Record); + // Coerce stringified JSON values (arrays/objects) back to their proper types. + // Some LLMs serialize complex values as strings when the schema uses + // anyOf/oneOf (e.g., '["url"]' instead of ["url"] for anyOf: [array, null]). + fixStringifiedJsonValues( + data as Record, + anySchema as Record, + ); valid = validate(data); if (!valid && validate.errors) { @@ -121,6 +128,88 @@ export class SchemaValidator { * - "true", "True", "TRUE" -> true * - "false", "False", "FALSE" -> false */ +/** + * Returns the set of JSON Schema types that a property accepts, + * considering `type`, `anyOf`, and `oneOf` keywords. + */ +function getAcceptedTypes( + propSchema: Record, +): Set | null { + const types = new Set(); + + if (typeof propSchema['type'] === 'string') { + types.add(propSchema['type'] as string); + } else if (Array.isArray(propSchema['type'])) { + for (const t of propSchema['type'] as string[]) { + types.add(t); + } + } + + for (const keyword of ['anyOf', 'oneOf']) { + const variants = propSchema[keyword]; + if (Array.isArray(variants)) { + for (const variant of variants as Array>) { + if (typeof variant['type'] === 'string') { + types.add(variant['type'] as string); + } + } + } + } + + return types.size > 0 ? types : null; +} + +/** + * Coerces stringified JSON values back to their proper types. + * Some LLMs serialize arrays/objects as JSON strings when the schema uses + * anyOf/oneOf with mixed types (e.g., `list[str] | None` in Python becomes + * `anyOf: [{type: "array"}, {type: "null"}]`). The model may return + * '["url"]' (a string) instead of ["url"] (an actual array). + * + * This function parses such strings back to their intended type when: + * 1. The value is a string starting with `[` or `{` + * 2. The schema accepts array or object but not string + * 3. The parsed result matches one of the accepted types + */ +function fixStringifiedJsonValues( + data: Record, + schema: Record, +) { + const properties = schema['properties'] as + | Record> + | undefined; + if (!properties) return; + + for (const key of Object.keys(data)) { + const value = data[key]; + const propSchema = properties[key]; + if (!propSchema || typeof value !== 'string') continue; + + const trimmed = value.trim(); + if ( + (trimmed.startsWith('[') && trimmed.endsWith(']')) || + (trimmed.startsWith('{') && trimmed.endsWith('}')) + ) { + const accepted = getAcceptedTypes(propSchema); + if (!accepted) continue; + // Only coerce if the schema does NOT accept string — otherwise the + // string value may be intentional. + if (accepted.has('string')) continue; + if (!accepted.has('array') && !accepted.has('object')) continue; + + try { + const parsed = JSON.parse(trimmed); + const parsedType = Array.isArray(parsed) ? 'array' : typeof parsed; + if (accepted.has(parsedType)) { + data[key] = parsed; + } + } catch { + // Not valid JSON — leave the value unchanged + } + } + } +} + function fixBooleanValues(data: Record) { for (const key of Object.keys(data)) { if (!(key in data)) continue; From 6fd29b698bd6f5a5b9dcc6d6c783d01c34b2309a Mon Sep 17 00:00:00 2001 From: chiga0 Date: Sat, 4 Apr 2026 20:43:06 +0800 Subject: [PATCH 03/12] fix: address PR review feedback for verbose/compact mode toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change default verboseMode to true (preserving current UX behavior) - Fix compact mode hiding active shell output (add forceShowResult + isUserInitiated) - Fix asymmetric frozen snapshot (freeze on ANY toggle during streaming) - Fix copyright header in VerboseModeContext.tsx (Google LLC → Qwen) - Add proper translations for all 6 locales (de/ja/pt/ru/zh/en) - Rewrite CompactToolGroupDisplay with bordered box, i18n hint, shell detection - Fix Pending status color (theme.text.secondary instead of theme.status.success) - Fix description casing: ctrl+o → Ctrl+O - Add explanatory comment for useCallback settings dependency Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/config/settingsSchema.ts | 4 +- packages/cli/src/i18n/locales/de.js | 10 +- packages/cli/src/i18n/locales/en.js | 8 + packages/cli/src/i18n/locales/ja.js | 9 +- packages/cli/src/i18n/locales/pt.js | 10 +- packages/cli/src/i18n/locales/ru.js | 10 +- packages/cli/src/i18n/locales/zh.js | 9 +- packages/cli/src/ui/AppContainer.tsx | 9 +- .../src/ui/components/HistoryItemDisplay.tsx | 1 + .../__snapshots__/Footer.test.tsx.snap | 4 +- .../messages/CompactToolGroupDisplay.tsx | 148 ++++++++++++++++++ .../components/messages/ToolGroupMessage.tsx | 45 ++++-- .../ui/components/messages/ToolMessage.tsx | 9 +- .../src/ui/contexts/VerboseModeContext.tsx | 4 +- .../ui/hooks/shellCommandProcessor.test.ts | 1 + .../cli/src/ui/hooks/shellCommandProcessor.ts | 2 + packages/cli/src/ui/types.ts | 1 + .../schemas/settings.schema.json | 4 +- 18 files changed, 261 insertions(+), 27 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index e6a964260..075b311e0 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -545,9 +545,9 @@ const SETTINGS_SCHEMA = { label: 'Verbose Mode', category: 'UI', requiresRestart: false, - default: false, + default: true, description: - '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).', showInDialog: false, }, }, diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index aaf814ee1..fe1766fe2 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1948,5 +1948,13 @@ 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', + verbose: 'ausführlich', + 'Verbose mode on — showing full tool output and thinking': + 'Ausführlicher Modus aktiv — vollständige Tool-Ausgabe und Denkprozess werden angezeigt', + 'Compact mode on — showing tool names and final responses only': + 'Kompakter Modus aktiv — nur Tool-Namen und abschließende Antworten werden angezeigt', + '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 544dfb45c..13a48f462 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1990,4 +1990,12 @@ export default { '(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', + 'Verbose mode on — showing full tool output and thinking': + 'Verbose mode on — showing full tool output and thinking', + 'Compact mode on — showing tool names and final responses only': + 'Compact mode on — showing tool names and final responses only', + '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 4b4bb53fb..d5601139b 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1441,5 +1441,12 @@ export default { 'Rawモードが利用できません。インタラクティブターミナルで実行してください。', '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(↑ ↓ 矢印キーで移動、Enter で選択、Ctrl+C で終了)\n', - verbose: 'verbose', + verbose: '詳細', + 'Verbose mode on — showing full tool output and thinking': + '詳細モードオン — 完全なツール出力と思考を表示しています', + 'Compact mode on — showing tool names and final responses only': + 'コンパクトモードオン — ツール名と最終応答のみ表示しています', + '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 928f198a2..80ec4dd9d 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1938,5 +1938,13 @@ 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', + verbose: 'detalhado', + 'Verbose mode on — showing full tool output and thinking': + 'Modo detalhado ativado — exibindo saída completa da ferramenta e raciocínio', + 'Compact mode on — showing tool names and final responses only': + 'Modo compacto ativado — exibindo apenas nomes de ferramentas e respostas finais', + '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 ee0cfe69b..9d59998ef 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1945,5 +1945,13 @@ export default { 'Raw-режим недоступен. Пожалуйста, запустите в интерактивном терминале.', '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(↑ ↓ стрелки для навигации, Enter для выбора, Ctrl+C для выхода)\n', - verbose: 'verbose', + verbose: 'подробный', + 'Verbose mode on — showing full tool output and thinking': + 'Подробный режим включён — отображается полный вывод инструментов и рассуждения', + 'Compact mode on — showing tool names and final responses only': + 'Компактный режим включён — отображаются только имена инструментов и окончательные ответы', + '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 56b747dcf..3c1501926 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1795,5 +1795,12 @@ export default { '原始模式不可用。请在交互式终端中运行。', '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(使用 ↑ ↓ 箭头导航,Enter 选择,Ctrl+C 退出)\n', - verbose: '详细模式', + verbose: '详细', + 'Verbose mode on — showing full tool output and thinking': + '已切换到详细模式 — 完整显示工具输出和思考过程', + 'Compact mode on — showing tool names and final responses only': + '已切换到精简模式 — 仅显示工具名称和最终回答', + '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 b4b3a4dd9..b829645cf 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -970,7 +970,7 @@ export const AppContainer = (props: AppContainerProps) => { useState(false); const [verboseMode, setVerboseMode] = useState( - settings.merged.ui?.verboseMode ?? false, + settings.merged.ui?.verboseMode ?? true, ); const [frozenSnapshot, setFrozenSnapshot] = useState< HistoryItemWithoutId[] | null @@ -1371,7 +1371,9 @@ export const AppContainer = (props: AppContainerProps) => { setVerboseMode(newValue); void settings.setValue(SettingScope.User, 'ui.verboseMode', newValue); refreshStatic(); - if (newValue && streamingState !== StreamingState.Idle) { + // Symmetric freeze: capture snapshot on ANY toggle during streaming + // (not just to verbose) to prevent abrupt content changes. + if (streamingState !== StreamingState.Idle) { setFrozenSnapshot([...pendingHistoryItems]); } else { setFrozenSnapshot(null); @@ -1404,6 +1406,9 @@ export const AppContainer = (props: AppContainerProps) => { btwItem, setBtwItem, cancelBtw, + // `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, diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index bf31c9e8a..ad5f8a4f0 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -180,6 +180,7 @@ const HistoryItemDisplayComponent: React.FC = ({ isFocused={isFocused} activeShellPtyId={activeShellPtyId} embeddedShellFocused={embeddedShellFocused} + isUserInitiated={itemForDisplay.isUserInitiated} /> )} {itemForDisplay.type === 'compression' && ( 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 e22196178..13017da60 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[`