diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index a47f840216e..ccd096be084 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -138,6 +138,7 @@ These settings are read from operator scopes only (User, System, and SystemDefau | `ui.history.collapseOnResume` | boolean | Whether to collapse history by default when resuming a session. Can be toggled via `/history collapse-on-resume` and `/history expand-on-resume`. | `false` | | `ui.history.collapsePreviewCount` | number | Number of most recent user turns to keep visible when `ui.history.collapseOnResume` is enabled. `0` collapses all restored history by default; `-1` shows all restored history. | `0` | | `ui.compactMode` | boolean | **RETIRED everywhere.** The CLI now always shows the compact, type-based tool view in the main transcript; press `Ctrl+O` to toggle expanded detail mode (expand or collapse all thinking blocks and tool outputs inline) instead of toggling a mode, and the web shell now fixes its compact view on as well. The key is kept only so existing settings files do not warn; writes are accepted but nothing reads the value. | `false` | +| `ui.showToolCallArgs` | boolean | Render tool calls on their own line with their full raw arguments inline, instead of the type-based compact summary that folds read/search/list batches into `Read 3 files`. Recovers parameters the per-tool description summarizes away (e.g. `Edit` normally shows only the filename). Useful when debugging MCP integrations or tool schemas. The args row is capped at 2 wrapped lines (and at most 1000 characters), so a batch of pending calls cannot outgrow the terminal; press `Ctrl+O` to lift the cap and expand result output too. Two cases keep the compact view: groups of running parallel subagents, which the live agent roster owns — expanding them re-inflates the live frame past the terminal height (#5798) — and daemon-attached sessions, which do not carry arguments across the daemon boundary. Use `Ctrl+O` there. TUI only — the web shell is unaffected. | `false` | | `ui.shellOutputMaxLines` | number | Max number of shell output lines shown inline. Set to `0` to disable the cap and show full output. Hidden lines are surfaced via the `+N lines` indicator. Errors, `!`-prefix user-initiated commands, confirming tools, and focused embedded shells always show full output. | `5` | | `ui.enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. If you choose **Start new chat session**, that choice is remembered for the current project until the project summary changes. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` | | `ui.accessibility.enableLoadingPhrases` | boolean | Enable loading phrases (disable for accessibility). | `true` | diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index fe9e630eee9..73fd43c8aa6 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -634,6 +634,18 @@ describe('SettingsSchema', () => { expect(mouseTracking.requiresRestart).toBe(true); }); + it('should have showToolCallArgs in ui settings', () => { + const showToolCallArgs = + getSettingsSchema().ui.properties.showToolCallArgs; + expect(showToolCallArgs).toBeDefined(); + expect(showToolCallArgs.type).toBe('boolean'); + // Default must stay false — the compact tool view is the baseline. + expect(showToolCallArgs.default).toBe(false); + expect(showToolCallArgs.showInDialog).toBe(true); + // Read at render time, so no restart is needed. + expect(showToolCallArgs.requiresRestart).toBe(false); + }); + it('should expose response tokens/sec as an opt-in UI setting', () => { const responseTokensPerSecond = getSettingsSchema().ui.properties.showResponseTokensPerSecond; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 44d01f8445c..57f322f5518 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1220,6 +1220,16 @@ const SETTINGS_SCHEMA = { 'Enable in-app SGR mouse tracking. While enabled, Qwen Code captures mouse events for text selection, click-to-position in text inputs, row hover, history-item toggling, and viewport scrolling. Because the terminal forwards all mouse events to the app, Qwen Code supplies its own equivalents for what the terminal can no longer do natively: a single click opens an http(s) hyperlink under the pointer (other link schemes are copied to the clipboard), and right-click over a link or a text selection opens an in-app context menu with Open Link / Copy Link Address / Copy Selection. Disable to hand the mouse fully back to the terminal (native right-click menu and link clicks); this turns off all in-app mouse interaction, and in Virtualized History the wheel no longer scrolls the transcript — use Shift+↑/↓, PgUp/PgDn, or Ctrl+Home/End instead (pair with ui.useTerminalBuffer: false to restore native terminal scrollback).', showInDialog: true, }, + showToolCallArgs: { + type: 'boolean', + label: 'Show Tool Call Arguments', + category: 'UI', + requiresRestart: false, + default: false, + description: + 'Render tool calls on their own line with their raw arguments inline, instead of the type-based compact summary that folds read/search/list batches into "Read 3 files". Useful when debugging MCP integrations or tool schemas. Applies wherever the arguments are available: live, resumed, agent-view and speculated turns. The row is capped at 2 wrapped lines (and never more than 1000 characters) and truncated with a `+N chars` marker; press Ctrl+O for the complete payload. Groups of running parallel subagents keep their compact roster, and daemon-attached sessions carry no arguments, so both keep the compact view — press Ctrl+O there. Does not change result-output truncation.', + showInDialog: true, + }, shellOutputMaxLines: { type: 'number', label: 'Shell Output Max Lines', diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 1d3192c546a..97770a5c4d6 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -477,6 +477,7 @@ export default { 'Hide Window Title': 'Amaga el títol de la finestra', 'Show Status in Title': "Mostra l'estat al títol", 'Hide Tips': 'Amaga els consells', + 'Show Tool Call Arguments': 'Mostra els arguments de les crides a eines', 'Show Line Numbers in Code': 'Mostra els números de línia al codi', 'Show Citations': 'Mostra les cites', 'Custom Witty Phrases': 'Frases enginyoses personalitzades', diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 3f6f7d711e9..3a6571ddda9 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -409,6 +409,7 @@ export default { 'Hide Window Title': 'Fenstertitel ausblenden', 'Show Status in Title': 'Status im Titel anzeigen', 'Hide Tips': 'Tipps ausblenden', + 'Show Tool Call Arguments': 'Tool-Aufrufargumente anzeigen', 'Show Line Numbers in Code': 'Zeilennummern im Code anzeigen', 'Show Citations': 'Quellenangaben anzeigen', 'Custom Witty Phrases': 'Benutzerdefinierte Witzige Sprüche', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 43e177aacb1..9ac159a00dc 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -734,6 +734,7 @@ export default { 'Hide Window Title': 'Hide Window Title', 'Show Status in Title': 'Show Status in Title', 'Hide Tips': 'Hide Tips', + 'Show Tool Call Arguments': 'Show Tool Call Arguments', 'Show Line Numbers in Code': 'Show Line Numbers in Code', 'Show Citations': 'Show Citations', 'Custom Witty Phrases': 'Custom Witty Phrases', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index aa47c76457a..d49db98060b 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -484,6 +484,7 @@ export default { 'Hide Window Title': 'Masquer le titre de la fenêtre', 'Show Status in Title': 'Afficher le statut dans le titre', 'Hide Tips': 'Masquer les conseils', + 'Show Tool Call Arguments': 'Afficher les arguments des appels d’outils', 'Show Line Numbers in Code': 'Afficher les numéros de ligne dans le code', 'Show Citations': 'Afficher les citations', 'Custom Witty Phrases': 'Phrases personnalisées spirituelles', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 911a9925e50..fd23486d7ab 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -357,6 +357,7 @@ export default { 'Vim Mode': 'Vim モード', 'Output Format': '出力形式', 'Hide Tips': 'ヒントを非表示', + 'Show Tool Call Arguments': 'ツール呼び出し引数を表示', Text: 'テキスト', JSON: 'JSON', Plan: 'プラン', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 61c278b14fa..f0342f5446b 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -431,6 +431,7 @@ export default { 'Hide Window Title': 'Ocultar Título da Janela', 'Show Status in Title': 'Mostrar Status no Título', 'Hide Tips': 'Ocultar Dicas', + 'Show Tool Call Arguments': 'Mostrar Argumentos das Chamadas de Ferramenta', 'Show Line Numbers in Code': 'Mostrar Números de Linhas no Código', 'Show Citations': 'Mostrar Citações', 'Custom Witty Phrases': 'Frases de Efeito Personalizadas', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 2b1d2d774bb..315ad32b041 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -429,6 +429,7 @@ export default { 'Hide Window Title': 'Скрыть заголовок окна', 'Show Status in Title': 'Показывать статус в заголовке', 'Hide Tips': 'Скрыть подсказки', + 'Show Tool Call Arguments': 'Показывать аргументы вызовов инструментов', 'Show Line Numbers in Code': 'Показывать номера строк в коде', 'Show Citations': 'Показывать цитаты', 'Custom Witty Phrases': 'Пользовательские остроумные фразы', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index c76a4b473e6..97cc672e056 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -695,6 +695,7 @@ export default { 'Hide Window Title': '隱藏窗口標題', 'Show Status in Title': '在標題中顯示狀態', 'Hide Tips': '隱藏提示', + 'Show Tool Call Arguments': '顯示工具呼叫參數', 'Show Line Numbers in Code': '在代碼中顯示行號', 'Show Citations': '顯示引用', 'Custom Witty Phrases': '自定義詼諧短語', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index af52696c66c..788e2e03f98 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -737,6 +737,7 @@ export default { 'Hide Window Title': '隐藏窗口标题', 'Show Status in Title': '在标题中显示状态', 'Hide Tips': '隐藏提示', + 'Show Tool Call Arguments': '显示工具调用参数', 'Show Line Numbers in Code': '在代码中显示行号', 'Show Citations': '显示引用', 'Custom Witty Phrases': '自定义诙谐短语', diff --git a/packages/cli/src/serve/routes/workspace-settings.test.ts b/packages/cli/src/serve/routes/workspace-settings.test.ts index c3207a38b61..fe6a77a01ec 100644 --- a/packages/cli/src/serve/routes/workspace-settings.test.ts +++ b/packages/cli/src/serve/routes/workspace-settings.test.ts @@ -606,7 +606,7 @@ describe('POST /workspace/settings', () => { expect(persistSetting).not.toHaveBeenCalled(); }); - it.each(['ui.mouseTracking', 'ui.showScrollbar'])( + it.each(['ui.mouseTracking', 'ui.showScrollbar', 'ui.showToolCallArgs'])( 'rejects a TUI-only key (%s) that has no effect in the web shell', async (key) => { // These keys are read only inside the ink TUI (mouseTracking also diff --git a/packages/cli/src/serve/routes/workspace-settings.ts b/packages/cli/src/serve/routes/workspace-settings.ts index 61268fc2af2..07349c5b6fd 100644 --- a/packages/cli/src/serve/routes/workspace-settings.ts +++ b/packages/cli/src/serve/routes/workspace-settings.ts @@ -41,6 +41,7 @@ const TUI_ONLY_SETTINGS = new Set([ 'general.outputLanguage', 'ide.enabled', 'ui.showLineNumbers', + 'ui.showToolCallArgs', 'ui.renderMode', 'ui.useTerminalBuffer', 'ui.mouseTracking', diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index c9c0550fa22..9a836e82a6d 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -69,6 +69,7 @@ import { AppContainer, countActiveScheduledTasks, dedupeNewestFirst, + buildSpeculativeToolDisplays, getSpeculativeToolResult, getNextRenderMode, getScheduledTasksStartupWarning, @@ -633,6 +634,33 @@ describe('AppContainer State Management', () => { status: ToolCallStatus.Success, }); }); + + it('carries the functionCall args onto the display object', () => { + // The fourth builder of IndividualToolCallDisplay. Without the args the + // setting half-applies: an accepted speculation falls back to the + // compact summary while live and resumed turns of the same shape show + // their arguments. + const args = { file_path: 'src/a.ts', old_string: 'x', new_string: 'y' }; + const tools = buildSpeculativeToolDisplays( + [{ functionCall: { name: 'replace', args } }], + [{ functionResponse: { response: { output: 'done' } } }], + ); + + expect(tools).toHaveLength(1); + expect(tools[0]!.args).toEqual(args); + expect(tools[0]!.name).toBe('replace'); + expect(tools[0]!.status).toBe(ToolCallStatus.Success); + }); + + it('falls back to an empty args object when the call carries none', () => { + const tools = buildSpeculativeToolDisplays( + [{ functionCall: { name: 'ls' } }], + [], + ); + // formatInlineToolArgs skips empty objects, so this renders no args row. + expect(tools[0]!.args).toEqual({}); + expect(tools[0]!.description).toBe('ls'); + }); }); afterEach(() => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c8d6e5eaba7..e2c8f0f0fe6 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -25,9 +25,11 @@ import { type UIActions, } from './contexts/UIActionsContext.js'; import { ConfigContext } from './contexts/ConfigContext.js'; +import type { Part } from '@google/genai'; import { type HistoryItem, type HistoryItemUser, + type IndividualToolCallDisplay, ToolCallStatus, type HistoryItemWithoutId, } from './types.js'; @@ -624,6 +626,41 @@ export function getSpeculativeToolResult(response: unknown): { }; } +/** + * Builds the tool display rows for an accepted speculation. + * + * Extracted from the submit handler so the fourth `IndividualToolCallDisplay` + * builder is unit-testable like its siblings (`mapToDisplay`, the resume path, + * the agent-view adapter) — in particular that it carries the raw `args` that + * `ui.showToolCallArgs` renders. + */ +export function buildSpeculativeToolDisplays( + toolCalls: Part[], + toolResults: Part[], +): IndividualToolCallDisplay[] { + return toolCalls.map((tc, i) => { + const name = tc.functionCall?.name ?? 'unknown'; + const args = (tc.functionCall?.args ?? {}) as Record; + const resp = toolResults[i]?.functionResponse?.response; + const speculativeResult = getSpeculativeToolResult(resp); + return { + callId: `spec-${name}-${i}`, + name, + description: + Object.entries(args) + .map(([k, v]) => `${k}: ${String(v).slice(0, 80)}`) + .join(', ') || name, + // Carried like the live, resume and agent-view builders so + // `ui.showToolCallArgs` renders the args row for an accepted + // speculation too. + args, + resultDisplay: speculativeResult.text.slice(0, 500), + status: speculativeResult.status, + confirmationDetails: undefined, + }; + }); +} + function getResponseCandidateTokens( pendingLlmHistoryItems: HistoryItemWithoutId[], ): number { @@ -3092,23 +3129,10 @@ export const AppContainer = (props: AppContainerProps) => { const toolResults = nextMsg?.parts?.filter((p) => p.functionResponse) ?? []; - const tools = toolCalls.map((tc, i) => { - const name = tc.functionCall?.name ?? 'unknown'; - const args = tc.functionCall?.args ?? {}; - const resp = toolResults[i]?.functionResponse?.response; - const speculativeResult = getSpeculativeToolResult(resp); - return { - callId: `spec-${name}-${i}`, - name, - description: - Object.entries(args) - .map(([k, v]) => `${k}: ${String(v).slice(0, 80)}`) - .join(', ') || name, - resultDisplay: speculativeResult.text.slice(0, 500), - status: speculativeResult.status, - confirmationDetails: undefined, - }; - }); + const tools = buildSpeculativeToolDisplays( + toolCalls, + toolResults, + ); const toolGroupItem: HistoryItemWithoutId = { type: 'tool_group' as const, diff --git a/packages/cli/src/ui/components/agent-view/AgentChatContent.fullDetail.test.tsx b/packages/cli/src/ui/components/agent-view/AgentChatContent.fullDetail.test.tsx new file mode 100644 index 00000000000..21174c9fb7b --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/AgentChatContent.fullDetail.test.tsx @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Ctrl+O full-detail must reach the agent view's transcript items. + * + * Thinking blocks already honored the toggle here (HistoryItemDisplay reads + * ThoughtExpandedContext itself), but the tool side never received + * `fullDetail` — so `ui.showToolCallArgs` could render a truncated args row + * advertising `(ctrl+o)` for a key that did nothing in this view. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import { EventEmitter } from 'node:events'; +import { ThoughtExpandedProvider } from '../../contexts/ThoughtExpandedContext.js'; +import type { AgentCore } from '@qwen-code/qwen-code-core'; + +const receivedFullDetail: Array = []; + +vi.mock('../HistoryItemDisplay.js', () => ({ + HistoryItemDisplay: ({ fullDetail }: { fullDetail?: boolean }) => { + receivedFullDetail.push(fullDetail); + return item; + }, +})); + +vi.mock('../../contexts/UIStateContext.js', () => ({ + useUIState: () => ({ + historyRemountKey: 0, + availableTerminalHeight: 40, + constrainHeight: false, + }), +})); + +vi.mock('../../hooks/useTerminalSize.js', () => ({ + useTerminalSize: () => ({ columns: 100, rows: 40 }), +})); + +vi.mock('../../hooks/useKeypress.js', () => ({ useKeypress: () => {} })); + +vi.mock('../../contexts/AgentViewContext.js', () => ({ + useAgentViewActions: () => ({ setAgentShellFocused: () => {} }), +})); + +const { AgentChatContent } = await import('./AgentChatContent.js'); + +function makeCore(): AgentCore { + const emitter = new EventEmitter(); + return { + getEventEmitter: () => emitter, + getMessages: () => [ + { + role: 'assistant', + content: 'hello from the subagent', + timestamp: 0, + }, + // An unmatched tool_call maps to a tool_group whose tool is still + // Executing. `splitIndex` keeps such a group (and everything after it) + // in the live area, so this fixture is what makes the second, + // `isPending` render site run at all — without it the live-area + // forwarding is unpinned. + { + role: 'tool_call', + content: 'Tool call: replace', + timestamp: 0, + metadata: { + callId: 'c1', + toolName: 'replace', + description: 'src/a.ts', + args: { file_path: 'src/a.ts' }, + }, + }, + ], + getPendingApprovals: () => new Map(), + getLiveOutputs: () => new Map(), + getShellPids: () => new Map(), + runtimeContext: { getTargetDir: () => '' }, + modelConfig: { model: 'test-model' }, + } as unknown as AgentCore; +} + +const renderAt = (allExpanded: boolean) => { + receivedFullDetail.length = 0; + render( + (), + toggle: () => {}, + }} + > + + , + ); + return receivedFullDetail; +}; + +describe('AgentChatContent — Ctrl+O full detail', () => { + // Both render sites must be covered: the committed tree and the + // live area that holds executing/confirming tool groups. Pinning only the + // former let a mutation dropping the live-area prop survive, which would + // leave a running tool group advertising `(ctrl+o)` for a dead key. + const COMMITTED_AND_LIVE = 2; + + it('forwards the expanded state to both committed and live items', () => { + const seen = renderAt(true); + expect(seen.length).toBe(COMMITTED_AND_LIVE); + expect(seen).toEqual([true, true]); + }); + + it('leaves both sites collapsed when the toggle is off', () => { + const seen = renderAt(false); + expect(seen.length).toBe(COMMITTED_AND_LIVE); + expect(seen).toEqual([false, false]); + }); +}); diff --git a/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx b/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx index 3b017cadae6..7723436b9f7 100644 --- a/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx @@ -25,6 +25,7 @@ import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { useAgentViewActions } from '../../contexts/AgentViewContext.js'; import { HistoryItemDisplay } from '../HistoryItemDisplay.js'; +import { useThoughtExpanded } from '../../contexts/ThoughtExpandedContext.js'; import { ToolCallStatus } from '../../types.js'; import { theme } from '../../semantic-colors.js'; import { RespondingSpinner } from '../RespondingSpinner.js'; @@ -61,6 +62,11 @@ export const AgentChatContent = ({ const { historyRemountKey, availableTerminalHeight, constrainHeight } = uiState; const { columns: terminalWidth } = useTerminalSize(); + // Ctrl+O full-detail, matching MainContent. Thinking blocks in this view + // already honored the toggle (HistoryItemDisplay reads the context itself), + // but the tool side never received it — so a truncated args row could + // advertise `(ctrl+o)` for a key that did nothing here. + const { allExpanded: fullDetail } = useThoughtExpanded(); const contentWidth = terminalWidth - 4; // Force re-render on message updates and status changes. @@ -239,6 +245,7 @@ export const AgentChatContent = ({ terminalWidth={terminalWidth} mainAreaWidth={contentWidth} thoughtHeadId={thoughtHeadIdByItem.get(item)} + fullDetail={fullDetail} /> )), ]} @@ -255,6 +262,7 @@ export const AgentChatContent = ({ isPending={true} terminalWidth={terminalWidth} mainAreaWidth={contentWidth} + fullDetail={fullDetail} availableTerminalHeight={ constrainHeight ? availableTerminalHeight : undefined } diff --git a/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts b/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts index 3d9bbb5bc05..0fbb1585942 100644 --- a/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts +++ b/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts @@ -27,7 +27,11 @@ const noApprovals = new Map(); function toolCallMsg( callId: string, toolName: string, - opts?: { description?: string; renderOutputAsMarkdown?: boolean }, + opts?: { + description?: string; + renderOutputAsMarkdown?: boolean; + args?: Record; + }, ): AgentMessage { return msg('tool_call', `Tool call: ${toolName}`, { metadata: { @@ -35,6 +39,7 @@ function toolCallMsg( toolName, description: opts?.description ?? '', renderOutputAsMarkdown: opts?.renderOutputAsMarkdown, + ...(opts?.args ? { args: opts.args } : {}), }, }); } @@ -346,6 +351,21 @@ describe('agentMessagesToHistoryItems — tool metadata', () => { expect(group.tools[0]!.renderOutputAsMarkdown).toBe(true); }); + it('forwards args from tool_call so ui.showToolCallArgs works in the agent view', () => { + // Without this the setting half-applies: the same call shows its args in + // the main transcript but silently never does inside the agent view. + const args = { file_path: 'src/a.ts', old_string: 'x', new_string: 'y' }; + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'replace', { args })], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.args).toEqual(args); + }); + it('forwards description from tool_call', () => { const items = agentMessagesToHistoryItems( [toolCallMsg('c1', 'read', { description: 'reading src/index.ts' })], diff --git a/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts b/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts index 32f0eec6848..97aaf90f49b 100644 --- a/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts +++ b/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts @@ -86,6 +86,7 @@ export function agentMessagesToHistoryItems( callId: string; name: string; description: string; + args: Record | undefined; resultDisplay: ToolResultDisplay | string | undefined; outputFile: string | undefined; renderOutputAsMarkdown: boolean | undefined; @@ -108,6 +109,10 @@ export function agentMessagesToHistoryItems( callId, name: (m.metadata?.['toolName'] as string) ?? 'unknown', description: (m.metadata?.['description'] as string) ?? '', + // Contractually present for role='tool_call' (agent-types.ts). + // Carried like the live and resume builders so `ui.showToolCallArgs` + // renders the args row in the agent view too. + args: m.metadata?.['args'] as Record | undefined, resultDisplay: undefined, outputFile: undefined, renderOutputAsMarkdown: m.metadata?.['renderOutputAsMarkdown'] as @@ -137,6 +142,7 @@ export function agentMessagesToHistoryItems( callId, name: (m.metadata?.['toolName'] as string) ?? 'unknown', description: '', + args: undefined, resultDisplay, outputFile, renderOutputAsMarkdown: undefined, @@ -172,6 +178,7 @@ export function agentMessagesToHistoryItems( callId: entry.callId, name: entry.name, description: entry.description, + args: entry.args, resultDisplay, outputFile: entry.outputFile, renderOutputAsMarkdown: entry.renderOutputAsMarkdown, diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.heightBudget.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.heightBudget.test.tsx new file mode 100644 index 00000000000..efd20b23dd3 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.heightBudget.test.tsx @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect } from 'vitest'; +import type React from 'react'; +import { ToolGroupMessage } from './ToolGroupMessage.js'; +import type { IndividualToolCallDisplay } from '../../types.js'; +import { StreamingState, ToolCallStatus } from '../../types.js'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { ConfigContext } from '../../contexts/ConfigContext.js'; +import { SettingsContext } from '../../contexts/SettingsContext.js'; +import { StreamingContext } from '../../contexts/StreamingContext.js'; +import type { LoadedSettings } from '../../../config/settings.js'; + +/** + * The `ui.showToolCallArgs` args row renders through the REAL ToolMessage here + * — the sibling `ToolGroupMessage.test.tsx` mocks that component away, so it + * cannot see how tall the row actually draws. + * + * `ToolGroupMessage` budgets height as + * `availableTerminalHeight - staticHeight - countOneLineToolCalls`, hands the + * remainder to the result renderers only, and counts a tool with no + * `resultDisplay` as exactly one line. The args row is outside both, so while + * its only bound was 1000 *characters* a single pending batch drew far past the + * viewport (six calls measured ~72 rows into a 20-row frame). Once the live, + * non-`` frame exceeds the terminal height, ink's + * `shouldClearTerminalForFrame` wipes scrollback on every repaint (#5798). + */ +describe('ToolGroupMessage height budget under ui.showToolCallArgs', () => { + const AVAILABLE_TERMINAL_HEIGHT = 20; + const CONTENT_WIDTH = 100; + + const mockConfig = { + getShouldUseNodePtyShell: () => false, + } as unknown as Config; + + const renderWithArgsSetting = ( + component: React.ReactElement, + showToolCallArgs: boolean, + ) => + render( + + + + {component} + + + , + ); + + // Six executing edits, each with an args payload far past the character cap + // — the shape the reviewer's probe used. + const pendingBatch: IndividualToolCallDisplay[] = Array.from( + { length: 6 }, + (_unused, i) => ({ + callId: `edit-${i}`, + name: 'Edit', + description: `file${i}.ts`, + resultDisplay: undefined, + status: ToolCallStatus.Executing, + confirmationDetails: undefined, + renderOutputAsMarkdown: false, + args: { + file_path: `file${i}.ts`, + old_string: 'a'.repeat(2000), + new_string: 'b'.repeat(2000), + }, + }), + ); + + const frameLines = (frame: string) => frame.split('\n').length; + + it('keeps a pending batch inside the terminal height with the setting on', () => { + const { lastFrame } = renderWithArgsSetting( + , + true, + ); + const frame = lastFrame() ?? ''; + + // The rows are actually there — this is a bound, not a regression to the + // compact view. If the args row were dropped the next assertion would pass + // vacuously. + expect(frame).toContain('old_string'); + expect(frame).toContain('chars (ctrl+o)'); + // Each tool costs its header plus at most TOOL_ARGS_INLINE_MAX_LINES rows. + expect(frameLines(frame)).toBeLessThanOrEqual(AVAILABLE_TERMINAL_HEIGHT); + }); + + it('is not taller than the same batch with the setting off, by more than the args rows', () => { + const { lastFrame: offFrame } = renderWithArgsSetting( + , + false, + ); + const { lastFrame: onFrame } = renderWithArgsSetting( + , + true, + ); + + const off = frameLines(offFrame() ?? ''); + const on = frameLines(onFrame() ?? ''); + // 2 rows per call, no more: the growth is bounded by the line cap rather + // than by how long the payload happens to be. + expect(on - off).toBeLessThanOrEqual(2 * pendingBatch.length); + }); +}); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index e0eef4c2d8a..e1a2582eff8 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -19,11 +19,17 @@ import type { } from '@qwen-code/qwen-code-core'; import { TOOL_STATUS } from '../../constants.js'; import { ConfigContext } from '../../contexts/ConfigContext.js'; +import { SettingsContext } from '../../contexts/SettingsContext.js'; +import type { LoadedSettings } from '../../../config/settings.js'; // Global compact mode was removed (#5666); type-based tool rendering no longer // consumes a compact-mode context. // Mock child components to isolate ToolGroupMessage behavior -vi.mock('./ToolMessage.js', () => ({ +// Spread the real module so non-component exports (TOOL_ARGS_INLINE_MAX_LINES, +// which ToolGroupMessage reserves height with) keep their real values — a +// hand-written literal here would let the two drift apart silently. +vi.mock('./ToolMessage.js', async (importOriginal) => ({ + ...(await importOriginal()), ToolMessage: vi.fn( ({ callId, @@ -34,6 +40,7 @@ vi.mock('./ToolMessage.js', () => ({ resultDisplay, isFocused, forceShowResult, + showToolCallArgs, }: { callId: string; name: string; @@ -43,6 +50,7 @@ vi.mock('./ToolMessage.js', () => ({ resultDisplay?: unknown; isFocused?: boolean; forceShowResult?: boolean; + showToolCallArgs?: boolean; }) => { // Use the same constants as the real component const statusSymbolMap: Record = { @@ -74,6 +82,7 @@ vi.mock('./ToolMessage.js', () => ({ MockTool[{callId}]: {statusSymbol} {name} - {description} ({emphasis}) {forceShowResult ? ' [forceShow]' : ''} + {showToolCallArgs ? ' [args]' : ''} ); }, @@ -124,6 +133,225 @@ describe('', () => { , ); + const renderWithToolCallArgs = (component: React.ReactElement) => + render( + + + {component} + + , + ); + + describe('ui.showToolCallArgs', () => { + const readBatch = [ + createToolCall({ + callId: 'r1', + name: 'ReadFile', + description: 'a.ts', + args: { absolute_path: 'a.ts' }, + }), + createToolCall({ + callId: 'r2', + name: 'ReadFile', + description: 'b.ts', + args: { absolute_path: 'b.ts' }, + }), + createToolCall({ + callId: 'g1', + name: 'Grep', + description: 'pattern', + args: { pattern: 'pattern' }, + }), + ]; + + it('keeps the compact partition summary when the setting is off', () => { + const { lastFrame } = renderWithProviders( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('read a.ts, b.ts'); + expect(frame).not.toContain('MockTool'); + }); + + it('renders every collapsible tool individually when the setting is on', () => { + const { lastFrame } = renderWithToolCallArgs( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('MockTool[r1]'); + expect(frame).toContain('MockTool[r2]'); + expect(frame).toContain('MockTool[g1]'); + expect(frame).not.toContain('read a.ts, b.ts'); + }); + + it('forwards showToolCallArgs down to each ToolMessage', () => { + const { lastFrame } = renderWithToolCallArgs( + , + ); + expect(lastFrame() ?? '').toContain('[args]'); + }); + + it('does not force result output open (that stays Ctrl+O)', () => { + const { lastFrame } = renderWithToolCallArgs( + , + ); + expect(lastFrame() ?? '').not.toContain('[forceShow]'); + }); + + it('keeps the compact partition when no tool carries args (daemon path)', () => { + // Daemon-built groups never carry args across the boundary. Expanding + // them would give a noisier transcript and zero args rows, reading as + // "these tools were called with no arguments". + const daemonShaped = readBatch.map(({ args: _args, ...rest }) => rest); + const { lastFrame } = renderWithToolCallArgs( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('read a.ts, b.ts'); + expect(frame).not.toContain('MockTool'); + }); + + it('treats an empty args object as nothing to render', () => { + const emptyArgs = readBatch.map((t) => ({ ...t, args: {} })); + const { lastFrame } = renderWithToolCallArgs( + , + ); + expect(lastFrame() ?? '').toContain('read a.ts, b.ts'); + }); + + it('expands a memory-only group instead of collapsing to the badge', () => { + // "Wrote 1 memory" would hide the very parameters the setting exists to + // surface. + const memoryOps = [ + createToolCall({ + callId: 'm1', + name: 'WriteFile', + description: 'QWEN.md', + args: { file_path: 'QWEN.md', content: 'remember this' }, + isMemoryOp: 'write', + }), + ]; + const { lastFrame } = renderWithToolCallArgs( + , + ); + const frame = lastFrame() ?? ''; + expect(frame).toContain('MockTool[m1]'); + expect(frame).not.toContain('Wrote 1 memory'); + }); + + it('keeps the memory badge when the setting is off', () => { + const memoryOps = [ + createToolCall({ + callId: 'm1', + name: 'WriteFile', + description: 'QWEN.md', + args: { file_path: 'QWEN.md' }, + isMemoryOp: 'write', + }), + ]; + const { lastFrame } = renderWithProviders( + , + ); + expect(lastFrame() ?? '').toContain('Wrote 1 memory'); + }); + + it('keeps the dense panel for a pure parallel-agent group', () => { + // The deliberate carve-out: ToolGroupMessage documents that rendering + // running agents inline while LiveAgentPanel also lists them overflows + // the viewport and triggers ink's clear-screen scroll-snap loop (#5798). + // The setting must not reach that gate — this pins the exemption so a + // later "make it consistent" edit cannot silently reintroduce the bug. + const agent = (name: string): AgentResultDisplay => ({ + type: 'task_execution', + subagentName: name, + taskDescription: `${name} task`, + taskPrompt: `Run ${name}`, + status: 'completed', + toolCalls: [], + }); + const agents = [ + createToolCall({ + callId: 'agent-1', + name: 'agent', + status: ToolCallStatus.Success, + args: { prompt: 'review the diff' }, + resultDisplay: agent('reviewer'), + }), + createToolCall({ + callId: 'agent-2', + name: 'agent', + status: ToolCallStatus.Success, + args: { prompt: 'plan the work' }, + resultDisplay: agent('planner'), + }), + ]; + // InlineParallelAgentsDisplay reads the registry off config; the bare + // `{}` mockConfig would make `getBackgroundTaskRegistry` throw, ink + // would swallow it, and the frame would be empty — which a negative + // assertion alone would pass vacuously. Mirrors `registryConfig` below. + const registryConfig = { + getBackgroundTaskRegistry: () => ({ get: () => undefined }), + } as unknown as Config; + const { lastFrame } = render( + + + + + , + ); + const frame = lastFrame() ?? ''; + // Positive first: the dense panel really rendered (an empty frame would + // satisfy the negation below on its own). + expect(frame).toContain('Parallel agents'); + // And not per-agent ToolMessages. Ctrl+O (fullDetail) is the documented + // way to expand these — covered by its own test above. + expect(frame).not.toContain('MockSubagent[agent-1]'); + }); + + it('renders without a SettingsProvider (defaults to off)', () => { + const { lastFrame } = renderWithProviders( + , + ); + expect(lastFrame() ?? '').toContain('read a.ts, b.ts'); + }); + }); + describe('Golden Snapshots', () => { it('renders single successful tool call', () => { const toolCalls = [createToolCall()]; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index f3f4fceb26c..12f70a6aa27 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -9,7 +9,7 @@ import { Box, Text } from 'ink'; import { useMemo, useRef } from 'react'; import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; -import { ToolMessage } from './ToolMessage.js'; +import { TOOL_ARGS_INLINE_MAX_LINES, ToolMessage } from './ToolMessage.js'; import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import { CompactToolGroupDisplay, @@ -18,6 +18,7 @@ import { } from './CompactToolGroupDisplay.js'; import { InlineParallelAgentsDisplay } from './InlineParallelAgentsDisplay.js'; import { useConfig } from '../../contexts/ConfigContext.js'; +import { useShowToolCallArgs } from '../../hooks/use-show-tool-call-args.js'; import { ICON } from '../../constants.js'; import type { AgentResultDisplay } from '@qwen-code/qwen-code-core'; @@ -178,6 +179,7 @@ export const ToolGroupMessage: React.FC = ({ fullDetail = false, }) => { const config = useConfig(); + const showToolCallArgs = useShowToolCallArgs(); const hasConfirmingTool = toolCalls.some( (t) => t.status === ToolCallStatus.Confirming, @@ -224,6 +226,26 @@ export const ToolGroupMessage: React.FC = ({ [isPending, toolCalls], ); + // `ui.showToolCallArgs` may only tear down the compact partition when this + // group can actually pay for it with an args row — otherwise a `Read 3 files` + // fold expands into three rows carrying nothing, a noisier transcript that + // reads as "these tools were called with no arguments". + // + // The live path is where that bites: a batch invoked with `{}`. Daemon-built + // groups carry no args across the boundary either (see + // `daemon-tui-adapter.ts`), but they never reach this fold anyway — + // `isCollapsibleTool` keys on display names ('ReadFile') while the adapter + // fills `name` from the ACP kind ('read_file'), so an attached session is + // already one row per call, with or without this setting. + const hasRenderableToolCallArgs = useMemo( + () => + showToolCallArgs && + inlineToolCalls.some( + (t) => t.args != null && Object.keys(t.args).length > 0, + ), + [showToolCallArgs, inlineToolCalls], + ); + // Determine which subagent tools currently have a pending confirmation. // Must be called unconditionally (Rules of Hooks) — before any early return. const subagentsAwaitingApproval = useMemo( @@ -337,10 +359,13 @@ export const ToolGroupMessage: React.FC = ({ // Memory-only groups get their own compact rendering with read/write // counts. Check BEFORE the partition logic so they aren't routed through // the collapsible/non-collapsible split. Skipped in full-detail - // mode (fullDetail) so each memory op renders as its own full ToolMessage - // rather than collapsing to the "Recalled/Wrote N memories" badge. + // mode (fullDetail), and under `ui.showToolCallArgs`, so each memory op + // renders as its own full ToolMessage — otherwise "Wrote 1 memory" would + // hide the very parameters the setting exists to surface — rather than + // collapsing to the "Recalled/Wrote N memories" badge. const allMemOpsComplete = !fullDetail && + !hasRenderableToolCallArgs && isMemoryOnlyGroup && !hasErrorTool && toolCalls.every((t) => t.status === ToolCallStatus.Success); @@ -373,10 +398,13 @@ export const ToolGroupMessage: React.FC = ({ // must see full details: confirmation prompts, errors, user-initiated // batches, focused shells, terminal subagents. Full-detail // mode (fullDetail) also forces it so every tool renders individually - // instead of collapsing read/search into a partition summary. + // instead of collapsing read/search into a partition summary, as does + // `ui.showToolCallArgs` — an args row is meaningless on a batch that + // collapsed its calls into a single "Read 3 files" line. const hasTerminalSubagent = inlineToolCalls.some(isTerminalSubagentTool); const forceExpandAll = fullDetail || + hasRenderableToolCallArgs || hasConfirmingTool || hasSubagentPendingConfirmation || hasErrorTool || @@ -447,8 +475,23 @@ export const ToolGroupMessage: React.FC = ({ contentWidth, ); const memoryBadgeHeight = hasMemoryBadge ? 1 : 0; + // `ui.showToolCallArgs` draws an args row under each tool header. That row is + // bounded to `TOOL_ARGS_INLINE_MAX_LINES` wrapped rows (ToolMessage.tsx), but + // it renders outside `availableTerminalHeightPerToolMessage` (which only + // reaches the result renderers) and outside `countOneLineToolCalls` (which + // still counts a result-less tool as one line). Reserve it here, the way + // `collapsibleSummaryHeight` is reserved, so the per-tool result budget below + // does not hand out height the args rows have already spent. + const inlineArgsHeight = showToolCallArgs + ? nonCollapsibleTools.filter( + (t) => t.args != null && Object.keys(t.args).length > 0, + ).length * TOOL_ARGS_INLINE_MAX_LINES + : 0; const staticHeight = - /* marginBottom */ 1 + collapsibleSummaryHeight + memoryBadgeHeight; + /* marginBottom */ 1 + + collapsibleSummaryHeight + + memoryBadgeHeight + + inlineArgsHeight; // ToolConfirmationMessage still has its own padding={1}, so it needs // the -2 reservation. ToolMessage no longer pads itself (paddingX was // removed in the icon-alignment PR), so it gets the full contentWidth. @@ -513,6 +556,7 @@ export const ToolGroupMessage: React.FC = ({ embeddedShellFocused={embeddedShellFocused} config={config} fullDetail={fullDetail} + showToolCallArgs={showToolCallArgs} forceShowResult={ fullDetail || isUserInitiated || diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index ec5e9452f06..3b3b683da5e 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -7,7 +7,8 @@ import React from 'react'; import { render } from 'ink-testing-library'; import type { ToolMessageProps } from './ToolMessage.js'; -import { ToolMessage } from './ToolMessage.js'; +import { formatInlineToolArgs, ToolMessage } from './ToolMessage.js'; +import { toggleKeyHint } from './ConversationMessages.js'; import { StreamingState, ToolCallStatus } from '../../types.js'; import { Text } from 'ink'; import { StreamingContext } from '../../contexts/StreamingContext.js'; @@ -2006,3 +2007,210 @@ describe(' localized badge', () => { expect(lastFrame() ?? '').toContain('ReadFile'); }, 15000); }); + +describe('ToolMessage inline tool-call arguments (ui.showToolCallArgs)', () => { + const mockConfig = { + getShouldUseNodePtyShell: () => false, + } as unknown as Config; + + const argsProps: ToolMessageProps = { + callId: 'tool-args-1', + name: 'Edit', + description: 'src/foo.ts', + args: { file_path: 'src/foo.ts', old_string: 'a', new_string: 'b' }, + resultDisplay: undefined, + status: ToolCallStatus.Success, + contentWidth: 120, + confirmationDetails: undefined, + emphasis: 'medium', + config: mockConfig, + }; + + describe('formatInlineToolArgs', () => { + it('serializes args to one-line JSON', () => { + expect(formatInlineToolArgs({ a: 1, b: 'x' }, 'summary', false)).toBe( + '{"a":1,"b":"x"}', + ); + }); + + it('returns undefined for missing or empty args', () => { + expect(formatInlineToolArgs(undefined, 'summary', false)).toBeUndefined(); + expect(formatInlineToolArgs({}, 'summary', false)).toBeUndefined(); + }); + + it('skips the row when the description already IS the args JSON (MCP)', () => { + // DiscoveredMCPToolInvocation.getDescription() returns + // safeJsonStringify(params), so rendering both would print it twice. + const args = { owner: 'QwenLM', repo: 'qwen-code' }; + expect( + formatInlineToolArgs(args, JSON.stringify(args), false), + ).toBeUndefined(); + }); + + it('still renders when the description only resembles JSON', () => { + expect(formatInlineToolArgs({ a: 1 }, '{not json', false)).toBe( + '{"a":1}', + ); + }); + + it('still renders when a JSON description describes different args', () => { + expect(formatInlineToolArgs({ a: 1 }, '{"a":2}', false)).toBe('{"a":1}'); + }); + + it('caps the whole row at exactly 1000 columns when no width is known', () => { + // Pinned as literals: docs/users/configuration/settings.md promises "at + // most 1000 characters", and the marker is reserved INSIDE that budget + // (978 + 22 = 1000) so the `+N chars` tail is not what spills onto the + // row after the last one we are allowed to draw. A drifting cap or a + // corrupted `+N chars` counter must turn this red rather than ship green. + const args = { content: 'x'.repeat(5000) }; + const json = JSON.stringify(args); + expect(json).toHaveLength(5014); + + const out = formatInlineToolArgs(args, 'file.txt', false); + + expect(out).toBe(`${json.slice(0, 978)}… +4036 chars (${toggleKeyHint})`); + expect(out).toHaveLength(1000); + }); + + it('never cuts a surrogate pair in half at the cap boundary', () => { + // 973 x's put the emoji astride the head budget: it is the code point the + // cut lands on, which a code-unit slice would leave as a lone high + // surrogate — drawn as a replacement glyph in the terminal. + const args = { a: 'x'.repeat(973) + '\u{1F600}' }; + const json = JSON.stringify(args); + const out = formatInlineToolArgs(args, 'summary', false); + + expect(out).toBeDefined(); + // No unpaired surrogate anywhere in the rendered row. + expect(out).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/); + expect(out).not.toMatch(/(? { + // The row advertises what Ctrl+O will reveal, and Ctrl+O reveals + // characters. A code-unit count double-reports every emoji, so a payload + // of them would promise twice the content that actually exists — the + // same `toCodePoints` accounting the rest of this file uses. + const args = { a: 'x'.repeat(2000) + '\u{1F600}'.repeat(100) }; + const out = formatInlineToolArgs(args, 'summary', false); + const hidden = Number(/\+(\d+) chars/.exec(out ?? '')?.[1]); + + // 2000 x's + 100 emoji + the 8 structural chars of {"a":"…"} = 2108 code + // points; 978 of them are shown. + expect(hidden).toBe(2108 - 978); + }); + + it('bounds the row to two wrapped rows when the row width is known', () => { + // The height budget in ToolGroupMessage counts a result-less tool as one + // line and never sees this row, so a character-only cap let one pending + // batch draw past the terminal height (#5798). At width 40 the row may + // occupy 80 columns, not 1000. + const args = { content: 'x'.repeat(5000) }; + const out = formatInlineToolArgs(args, 'file.txt', false, 40); + + expect(out).toBeDefined(); + expect(out?.length).toBeLessThanOrEqual(80); + expect(out).toContain(`chars (${toggleKeyHint})`); + // Tighter of the two bounds wins: a very wide row still stops at 1000. + expect(formatInlineToolArgs(args, 'file.txt', false, 4000)).toHaveLength( + 1000, + ); + }); + + it('measures the row in columns, so full-width args wrap at half the count', () => { + // Columns, not code points, are what decide where ink wraps: a CJK + // argument fills the row in half the characters. + const args = { a: '固'.repeat(500) }; + const out = formatInlineToolArgs(args, 'summary', false, 40); + const head = out?.slice(0, out.indexOf('…')) ?? ''; + const cjkCount = (head.match(/固/g) ?? []).length; + + // 80 columns total, ~21 reserved for the marker: ~59 columns of head, + // which is ~29 double-width characters, not ~59. + expect(cjkCount).toBeGreaterThan(20); + expect(cjkCount).toBeLessThan(35); + }); + + it('lifts both caps in full-detail mode', () => { + const args = { content: 'x'.repeat(5000) }; + expect(formatInlineToolArgs(args, 'file.txt', true)).toBe( + JSON.stringify(args), + ); + expect(formatInlineToolArgs(args, 'file.txt', true, 40)).toBe( + JSON.stringify(args), + ); + }); + + it('strips bidi override characters from the rendered args', () => { + // Trojan Source (CVE-2021-42572): JSON.stringify escapes C0 controls but + // leaves U+202E alone, which would visually reorder the very payload + // this row exists to expose. + const out = formatInlineToolArgs( + { file_path: 'report\u202egpj.exe' }, + 'report', + false, + ); + expect(out).toBeDefined(); + expect(out).not.toMatch(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/); + expect(out).toContain('file_path'); + }); + + it('returns undefined for unserializable args instead of throwing', () => { + const circular: Record = {}; + circular['self'] = circular; + expect(formatInlineToolArgs(circular, 'summary', false)).toBeUndefined(); + }); + }); + + it('does not render the args row when the setting is off', () => { + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame() ?? ''; + expect(output).toContain('src/foo.ts'); + expect(output).not.toContain('old_string'); + }); + + it('renders the full raw args when the setting is on', () => { + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame() ?? ''; + // The parameters Edit's getDescription() drops are what the setting exists + // to recover. + expect(output).toContain('old_string'); + expect(output).toContain('new_string'); + }); + + it('prints an MCP payload once, not twice', () => { + const mcpArgs = { owner: 'QwenLM', repo: 'qwen-code' }; + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame() ?? ''; + expect(output.split('QwenLM').length - 1).toBe(1); + }); + + it('renders nothing extra when args are absent (daemon path)', () => { + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame() ?? ''; + expect(output).toContain('src/foo.ts'); + expect(output).not.toContain('{'); + }); +}); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 7d5c18e5fa1..39881b58761 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -53,6 +53,7 @@ import { toCodePoints, } from '../../utils/textUtils.js'; import { TOOL_DISPLAY_BY_NAME } from '../../utils/tool-display-map.js'; +import { toggleKeyHint } from './ConversationMessages.js'; import { ToolStatusIndicator, @@ -713,6 +714,14 @@ export interface ToolMessageProps extends IndividualToolCallDisplay { * error, confirming) still render the summary, never the full output. */ fullDetail?: boolean; + /** + * `ui.showToolCallArgs`. When true, an extra row under the tool header + * prints the raw `args` JSON, recovering parameters that + * `invocation.getDescription()` summarizes away (Edit shows only the + * filename, Read only the path). Independent of `fullDetail`, which owns + * result-output expansion; this one only ever adds the args row. + */ + showToolCallArgs?: boolean; /** * Whether this subagent owns keyboard input for the inline approval * surface — when true the focus-holder banner renders and the @@ -754,6 +763,8 @@ export const ToolMessage: React.FC = ({ config, forceShowResult, fullDetail, + showToolCallArgs, + args, isFocused, isPending, executionStartTime, @@ -938,6 +949,22 @@ export const ToolMessage: React.FC = ({ (effectiveDisplayRenderer.type === 'string' || effectiveDisplayRenderer.type === 'ansi'); + const inlineToolArgs = React.useMemo( + () => + showToolCallArgs + ? formatInlineToolArgs( + args, + description, + fullDetail === true, + // The row renders at `innerWidth` (the header's status-indicator + // gutter is padding, not content), so that is the width the + // line cap has to reason about. + innerWidth > 0 ? innerWidth : undefined, + ) + : undefined, + [showToolCallArgs, args, description, fullDetail, innerWidth], + ); + return ( @@ -971,6 +998,13 @@ export const ToolMessage: React.FC = ({ /> {emphasis === 'high' && } + {inlineToolArgs !== undefined && ( + + + {inlineToolArgs} + + + )} {visionBridgeNoticeText && ( = ({ ); }; +/** + * Absolute column cap for the inline args row in the main view. Generous enough + * for a real MCP payload, small enough that a WriteFile `content` arg cannot + * bury the conversation. Applies when the row width is unknown; otherwise + * whichever of this and `TOOL_ARGS_INLINE_MAX_LINES` is tighter wins. Lifted in + * full-detail mode — `ui.showToolCallArgs` gives you the args, Ctrl+O gives you + * everything. + */ +const TOOL_ARGS_INLINE_MAX_CHARS = 1000; + +/** + * Wrapped-row cap for the inline args row. + * + * `ToolGroupMessage` budgets terminal height per tool from + * `availableTerminalHeight - staticHeight - countOneLineToolCalls`, and that + * budget only ever reaches the result-output renderers — a tool with no + * `resultDisplay` is counted as exactly one line. The args row sits outside + * both, so a character-only cap let a single pending batch draw far past the + * viewport (six calls at the 1000-char cap measured ~72 rows into a 20-row + * frame). Once the live, non-`` frame exceeds the terminal height, + * ink's `shouldClearTerminalForFrame` wipes scrollback on every repaint — + * exactly the #5798 condition the parallel-agent hand-off above exists to + * avoid. Bounding the row in *rows* keeps a group's live frame proportional to + * its tool count; Ctrl+O remains the release valve. + */ +export const TOOL_ARGS_INLINE_MAX_LINES = 2; + +/** + * One-line JSON for the `ui.showToolCallArgs` row, or undefined when there is + * nothing worth adding. + * + * Skipped when `description` already IS the args JSON: MCP invocations return + * `safeJsonStringify(params)` from `getDescription()`, so rendering both would + * print the same payload twice. + * + * The result is model- and MCP-controlled text, so it goes through the same + * `sanitizeTerminalText` pipeline as the other untrusted renders in this + * component (`detailedDisplay`, the vision-bridge notice). `JSON.stringify` + * escapes C0 controls and `escapeAnsiCtrlCodes` neutralizes ESC-prefixed + * sequences, but neither touches Unicode bidi overrides — which would let a + * malicious arg visually reorder the very payload this row exists to expose + * (Trojan Source, CVE-2021-42572). + * + * Sanitization runs last, on the returned string: the dedup comparison and the + * `+N chars` accounting below both read the raw `json`, so the hidden-character + * count stays honest about the actual arguments. + * + * `rowWidth` is the width in columns the row renders at (`innerWidth` in the + * component). When given, the row is bounded to `TOOL_ARGS_INLINE_MAX_LINES` + * wrapped rows rather than by character count alone — see that constant. + */ +export function formatInlineToolArgs( + args: Record | undefined, + description: string, + uncapped: boolean, + rowWidth?: number, +): string | undefined { + if (!args || Object.keys(args).length === 0) { + return undefined; + } + + let json: string; + try { + json = JSON.stringify(args); + } catch { + // Circular or otherwise unserializable args — the header line is all we + // can honestly show. + return undefined; + } + + const trimmedDescription = description.trim(); + if (trimmedDescription.startsWith('{')) { + try { + if ( + JSON.stringify(JSON.parse(trimmedDescription) as unknown) === json || + trimmedDescription === json + ) { + return undefined; + } + } catch { + // Only looks like JSON — fall through and render the args row. + } + } + + if (uncapped) { + return sanitizeTerminalText(json); + } + + // Whichever bound is tighter. Without a known row width the column cap is all + // we have; with one, `TOOL_ARGS_INLINE_MAX_LINES` rows is the real ceiling. + const budget = + rowWidth !== undefined && rowWidth > 0 + ? Math.min( + TOOL_ARGS_INLINE_MAX_CHARS, + Math.floor(rowWidth) * TOOL_ARGS_INLINE_MAX_LINES, + ) + : TOOL_ARGS_INLINE_MAX_CHARS; + + // Reserve the marker's own columns inside the budget — otherwise the + // `+N chars` tail is precisely what spills onto the row after the last one we + // are allowed to draw. `json.length` is an upper bound on the digit count. + const markerWidth = `… +${json.length} chars (${toggleKeyHint})`.length; + const headBudget = Math.max(1, budget - markerWidth); + + // Walk code points, measuring columns. Two reasons not to `slice` code units: + // a raw cut can land between the halves of a surrogate pair (an emoji or a + // supplementary-plane CJK char in an argument) and leave an orphan the + // terminal draws as a replacement glyph; and columns, not code units, are + // what decide where ink wraps — a full-width CJK argument fills the row in + // half the characters. + let columns = 0; + let cut = -1; + for (let i = 0; i < json.length; ) { + const unit = json.charCodeAt(i); + const size = + unit >= 0xd800 && unit <= 0xdbff && i + 1 < json.length ? 2 : 1; + const width = Math.max(getCachedStringWidth(json.slice(i, i + size)), 1); + if (columns + width > headBudget) { + cut = i; + break; + } + columns += width; + i += size; + } + + if (cut < 0) { + return sanitizeTerminalText(json); + } + + // `+N chars` counts code points, matching the rest of this file's + // `toCodePoints` accounting: a code-unit count over-reports by one per astral + // character, so a payload of emoji would advertise twice what Ctrl+O reveals. + let hidden = 0; + for (let i = cut; i < json.length; ) { + const unit = json.charCodeAt(i); + i += unit >= 0xd800 && unit <= 0xdbff && i + 1 < json.length ? 2 : 1; + hidden++; + } + return sanitizeTerminalText( + `${json.slice(0, cut)}… +${hidden} chars (${toggleKeyHint})`, + ); +} + function isDescriptionRepeatedInPrompt( description: string, prompt: string, diff --git a/packages/cli/src/ui/hooks/use-show-tool-call-args.ts b/packages/cli/src/ui/hooks/use-show-tool-call-args.ts new file mode 100644 index 00000000000..fc3bf43e3bf --- /dev/null +++ b/packages/cli/src/ui/hooks/use-show-tool-call-args.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useContext } from 'react'; +import { SettingsContext } from '../contexts/SettingsContext.js'; + +/** + * Whether tool calls render verbosely: one row per call with its full raw + * arguments inline (`ui.showToolCallArgs`, default false). + * + * Two decision points share this flag and must not drift apart: + * 1. `ToolGroupMessage` — folds it into `forceExpandAll`, so read/search/list + * batches stop collapsing into a `Read 3 files` summary line. + * 2. `ToolMessage` — renders the args row under the tool header. + * + * Reads the raw context, not the throwing `useSettings`, so the tool renderers + * still mount outside a SettingsProvider (e.g. unit tests) — mirrors + * `useMouseTrackingEnabled`. + */ +export function useShowToolCallArgs(): boolean { + return useContext(SettingsContext)?.merged.ui?.showToolCallArgs === true; +} diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.test.tsx b/packages/cli/src/ui/hooks/useReactToolScheduler.test.tsx index 49a38bb8b7f..515a44154a9 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.test.tsx +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.test.tsx @@ -49,6 +49,40 @@ const makeSuccess = ( responseMedia: Part[] = [], ): TrackedToolCall => makeCompleted('success', displayName, responseMedia); +describe('mapToDisplay — raw args (ui.showToolCallArgs)', () => { + it('carries the request args through to the display object', () => { + const call = { + status: 'success', + request: { + callId: 'call-1', + name: 'edit', + args: { file_path: 'a.ts', old_string: 'x', new_string: 'y' }, + }, + tool: { displayName: 'Edit', isOutputMarkdown: false }, + invocation: { getDescription: () => 'a.ts' }, + response: { resultDisplay: 'ok', responseParts: [] }, + } as unknown as TrackedToolCall; + + // `description` summarizes the args away (Edit shows only the filename); + // the raw args are what the setting renders instead. + expect(mapToDisplay(call).tools[0].args).toEqual({ + file_path: 'a.ts', + old_string: 'x', + new_string: 'y', + }); + }); + + it('carries args through the error branch too', () => { + const call = { + status: 'error', + request: { callId: 'call-2', name: 'broken', args: { a: 1 } }, + response: { resultDisplay: 'boom', responseParts: [] }, + } as unknown as TrackedToolCall; + + expect(mapToDisplay(call).tools[0].args).toEqual({ a: 1 }); + }); +}); + describe('mapToDisplay — detailedDisplay (§4.9 live path)', () => { it('extracts detailedDisplay for a collapsible (read/search/list) tool', () => { const group = mapToDisplay(makeSuccess('Read File')); diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 896359540c3..8e8f127b99e 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -387,6 +387,9 @@ export function mapToDisplay( callId: trackedCall.request.callId, name: displayName, description, + // Same object reference the scheduler already holds — rendered only + // when `ui.showToolCallArgs` is on (see ToolMessage's args row). + args: trackedCall.request.args as Record, renderOutputAsMarkdown, isMemoryOp: projectRoot && trackedCall.status !== 'error' diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 7208905ebd9..f96f54744d4 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -74,6 +74,18 @@ export interface IndividualToolCallDisplay { callId: string; name: string; description: string; + /** + * Raw tool-call arguments, rendered inline under the header when + * `ui.showToolCallArgs` is on. `description` is only ever a human summary + * (`invocation.getDescription()`) — for most built-in tools it drops the + * actual parameters (Edit shows just the filename), which is what the + * setting exists to recover. + * + * Holds the same object reference as the scheduler's `request.args` — no + * copy, so it costs nothing in memory. Undefined on the daemon path, which + * never carries args across the boundary; the args row is then skipped. + */ + args?: Record; resultDisplay: ToolResultDisplay | string | undefined; visionBridgeNotice?: string; /** diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index 66b69599f5f..aa7b79a8cad 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -590,6 +590,7 @@ describe('resumeHistoryUtils', () => { callId: 'call-1', name: 'Replace', description: 'Mocked description', + args: { old: 'a', new: 'b' }, resultDisplay: 'All set', status: ToolCallStatus.Success, confirmationDetails: undefined, @@ -953,6 +954,7 @@ describe('resumeHistoryUtils', () => { callId: 'missing-call', name: 'unknown_tool', description: '', + args: { foo: 'bar' }, resultDisplay: { summary: 'failure' }, status: ToolCallStatus.Error, confirmationDetails: undefined, @@ -1044,6 +1046,7 @@ describe('resumeHistoryUtils', () => { callId: 'call-2', name: 'Replace', description: 'Mocked description', + args: { target: 'a' }, resultDisplay: undefined, status: ToolCallStatus.Success, confirmationDetails: undefined, @@ -1446,6 +1449,51 @@ describe('resumeHistoryUtils', () => { ]); }); + describe('raw args on resume (ui.showToolCallArgs)', () => { + type ToolGroupItem = Extract; + + it('carries the persisted functionCall args onto the display object', () => { + const editTool = { + name: 'replace', + displayName: 'Edit', + description: 'Edit a file', + build: vi.fn().mockReturnValue({ getDescription: () => 'a.ts' }), + } as unknown as AnyDeclarativeTool; + + const conversation = { + messages: [ + { + type: 'assistant', + message: { + parts: [ + { + functionCall: { + id: 'call-1', + name: 'replace', + args: { file_path: 'a.ts', old_string: 'x' }, + }, + } as unknown as Part, + ], + }, + }, + ], + } as unknown as ConversationRecord; + + const items = buildResumedHistoryItems( + { conversation } as ResumedSessionData, + makeConfig({ replace: editTool }), + 10, + ); + const tool = ( + items.find((i) => i.type === 'tool_group') as ToolGroupItem | undefined + )?.tools[0]; + + // A resumed session must show the same args row as a live one. + expect(tool?.description).toBe('a.ts'); + expect(tool?.args).toEqual({ file_path: 'a.ts', old_string: 'x' }); + }); + }); + describe('detailedDisplay (§4.9 Ctrl+O full detail on resume)', () => { type ToolGroupItem = Extract; const firstTool = (items: HistoryItem[]) => diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index cf99cabc967..17a88702af6 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -202,6 +202,7 @@ function convertToHistoryItems( callId: string; name: string; description: string; + args?: Record; resultDisplay: ToolResultDisplay | undefined; visionBridgeNotice?: string; detailedDisplay?: string; @@ -530,6 +531,9 @@ function convertToHistoryItems( callId: fc.id, name: tool?.displayName || fc.name, description: tool ? formatToolDescription(tool, fc.args) : '', + // Rendered inline only when `ui.showToolCallArgs` is on, so a + // resumed session shows the same args row as a live one. + args: fc.args, resultDisplay: undefined, status: ToolCallStatus.Success, // Will be updated by tool_result confirmationDetails: undefined, diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 1033e6b27f8..cc686f878f4 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -469,6 +469,11 @@ "type": "boolean", "default": true }, + "showToolCallArgs": { + "description": "Render tool calls on their own line with their raw arguments inline, instead of the type-based compact summary that folds read/search/list batches into \"Read 3 files\". Useful when debugging MCP integrations or tool schemas. Applies wherever the arguments are available: live, resumed, agent-view and speculated turns. The row is capped at 2 wrapped lines (and never more than 1000 characters) and truncated with a `+N chars` marker; press Ctrl+O for the complete payload. Groups of running parallel subagents keep their compact roster, and daemon-attached sessions carry no arguments, so both keep the compact view — press Ctrl+O there. Does not change result-output truncation.", + "type": "boolean", + "default": false + }, "shellOutputMaxLines": { "description": "Max number of shell output lines shown inline. Set to 0 to disable the cap and show full output. The hidden line count is still surfaced via the `+N lines` indicator.", "type": "number",