diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 7aee9b928..6dc6d1d02 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -109,6 +109,7 @@ Settings are organized into categories. All settings should be placed within the | `ui.showLineNumbers` | boolean | Show line numbers in code blocks in the CLI output. | `true` | | `ui.showCitations` | boolean | Show citations for generated text in the chat. | `true` | | `ui.compactMode` | boolean | Hide tool output and thinking for a cleaner view. Toggle with `Ctrl+O` during a session or via the Settings dialog. Tool approval prompts are never hidden, even in compact mode. The setting persists across sessions. | `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` | | `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` | | `ui.accessibility.screenReader` | boolean | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | `false` | diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 82818bf80..61a5164a0 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -707,6 +707,16 @@ const SETTINGS_SCHEMA = { 'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).', showInDialog: true, }, + shellOutputMaxLines: { + type: 'number', + label: 'Shell Output Max Lines', + category: 'UI', + requiresRestart: false, + default: 5, + 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.', + showInDialog: true, + }, }, }, diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 5056ce269..a8e57c8a1 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -38,9 +38,11 @@ vi.mock('../AnsiOutput.js', () => ({ AnsiOutputText: function MockAnsiOutputText({ data, maxWidth, + availableTerminalHeight, }: { data: AnsiOutput; maxWidth: number; + availableTerminalHeight?: number; }) { // Simple serialization for snapshot stability const serialized = data @@ -48,12 +50,19 @@ vi.mock('../AnsiOutput.js', () => ({ .join('\n'); return ( - MockAnsiOutput:{serialized}:width={maxWidth} + MockAnsiOutput:{serialized}:width={maxWidth}:height= + {availableTerminalHeight ?? 'undef'} ); }, - ShellStatsBar: function MockShellStatsBar() { - return null; + ShellStatsBar: function MockShellStatsBar({ + displayHeight, + }: { + displayHeight?: number; + }) { + return ( + MockShellStatsBar:displayHeight={displayHeight ?? 'undef'} + ); }, })); @@ -331,6 +340,288 @@ describe('', () => { expect(lastFrame()).toContain('width='); }); + it('caps shell ANSI output to default 5 lines when not forced', () => { + const ansiOutputDisplay: AnsiOutputDisplay = { + ansiOutput: [ + [ + { + text: 'a', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ], + totalLines: 50, + }; + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame()!; + expect(output).toContain('height=5'); + expect(output).toContain('MockShellStatsBar:displayHeight=5'); + }); + + it('does not cap non-shell ANSI output', () => { + const ansiOutputDisplay: AnsiOutputDisplay = { + ansiOutput: [ + [ + { + text: 'a', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ], + totalLines: 50, + }; + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame()!; + // availableHeight = 100 - STATIC_HEIGHT(1) - RESERVED_LINE_COUNT(5) = 94 + expect(output).toContain('height=94'); + }); + + it('bypasses cap when forceShowResult is true', () => { + const ansiOutputDisplay: AnsiOutputDisplay = { + ansiOutput: [ + [ + { + text: 'a', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ], + totalLines: 50, + }; + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame()!; + // availableHeight = 100 - STATIC_HEIGHT(1) - RESERVED_LINE_COUNT(5) = 94 + expect(output).toContain('height=94'); + }); + + it('disables cap when ui.shellOutputMaxLines is 0', () => { + const ansiOutputDisplay: AnsiOutputDisplay = { + ansiOutput: [ + [ + { + text: 'a', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ], + totalLines: 50, + }; + const settingsWithDisabledCap = { + merged: { ui: { shellOutputMaxLines: 0 } }, + } as unknown as LoadedSettings; + const { lastFrame } = render( + + + + + + + , + ); + const output = lastFrame()!; + expect(output).toContain('height=94'); + }); + + it('respects user-configured cap value', () => { + const ansiOutputDisplay: AnsiOutputDisplay = { + ansiOutput: [ + [ + { + text: 'a', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ], + totalLines: 50, + }; + const settingsWithCustomCap = { + merged: { ui: { shellOutputMaxLines: 12 } }, + } as unknown as LoadedSettings; + const { lastFrame } = render( + + + + + + + , + ); + const output = lastFrame()!; + expect(output).toContain('height=12'); + }); + + it('caps shell completed string output (returnDisplayMessage path)', () => { + // shell.ts emits the final result as a plain string via + // `returnDisplayMessage = result.output`, so the completed shell + // tool flows through StringResultRenderer, not the ANSI branch. + // The cap must still apply. + const longString = Array.from( + { length: 30 }, + (_, i) => `line ${i + 1}`, + ).join('\n'); + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame()!; + // With cap=5, the string path should show the last 5 content rows + // (the +1 height compensates for MaxSizedBox's overflow banner row, + // matching the ANSI path's 5 content rows + stats bar). + expect(output).not.toContain('line 1\n'); + expect(output).not.toContain('line 10'); + expect(output).toContain('line 26'); + expect(output).toContain('line 27'); + expect(output).toContain('line 28'); + expect(output).toContain('line 29'); + expect(output).toContain('line 30'); + }); + + it.each([ + ['negative', -1], + ['fractional', 1.5], + ['NaN-via-string', 'abc' as unknown as number], + ])('clamps %s shellOutputMaxLines to a safe value', (_label, badValue) => { + const ansiOutputDisplay: AnsiOutputDisplay = { + ansiOutput: [ + [ + { + text: 'a', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ], + totalLines: 50, + }; + const settingsWithBadCap = { + merged: { ui: { shellOutputMaxLines: badValue } }, + } as unknown as LoadedSettings; + const { lastFrame } = render( + + + + + + + , + ); + const output = lastFrame()!; + // -1 → 0 → cap disabled (height=94) + // 1.5 → 1 → cap to 1 (height=1) + // 'abc' → NaN → 0 → cap disabled (height=94) + if ( + typeof badValue === 'number' && + Number.isFinite(badValue) && + badValue > 0 + ) { + expect(output).toContain(`height=${Math.floor(badValue)}`); + } else { + expect(output).toContain('height=94'); + } + }); + + it('does not cap non-shell string output', () => { + const longString = Array.from( + { length: 30 }, + (_, i) => `line ${i + 1}`, + ).join('\n'); + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame()!; + // availableHeight = 94, well above 30 lines → all visible + expect(output).toContain('line 1'); + expect(output).toContain('line 30'); + }); + it('renders rejected plan content with plan text still visible', () => { const planResultDisplay = { type: 'plan_summary' as const, diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 8e37d4cae..0068c0c35 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -41,6 +41,7 @@ import { ToolElapsedTime } from '../shared/ToolElapsedTime.js'; const STATIC_HEIGHT = 1; const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc. const MIN_LINES_SHOWN = 2; // show at least this many lines +const DEFAULT_SHELL_OUTPUT_MAX_LINES = 5; // Large threshold to ensure we don't cause performance issues for very large // outputs that will get truncated further MaxSizedBox anyway. @@ -345,6 +346,33 @@ export const ToolMessage: React.FC = ({ MIN_LINES_SHOWN + 1, // enforce minimum lines shown ) : undefined; + // Cap inline shell output. Applies to both the streaming ANSI display and + // the completed string display (shell.ts emits the final result as a plain + // string via `returnDisplayMessage = result.output`). ShellStatsBar surfaces + // hidden lines via `+N lines` for ANSI; MaxSizedBox handles overflow for string. + const isShellTool = name === SHELL_COMMAND_NAME || name === SHELL_NAME; + const rawShellCap = + settings.merged.ui?.shellOutputMaxLines ?? DEFAULT_SHELL_OUTPUT_MAX_LINES; + // Defensive: clamp non-negative integers; treat negatives / NaN / fractions + // as the user's clear intent (0 = disable, otherwise floor to whole rows). + const shellOutputMaxLines = Math.max(0, Math.floor(rawShellCap || 0)); + const isCappingShell = + isShellTool && + shellOutputMaxLines > 0 && + !forceShowResult && + !isThisShellFocused; + const shellCapHeight = isCappingShell + ? Math.min(availableHeight ?? shellOutputMaxLines, shellOutputMaxLines) + : availableHeight; + // String path: MaxSizedBox reserves one row for its overflow banner when + // content overflows (see MaxSizedBox.tsx visibleContentHeight = max - 1), + // so passing the bare cap shows N-1 content rows. ANSI pre-slices to N + // (no MaxSizedBox overflow) and renders N rows + the ShellStatsBar line. + // +1 keeps the two paths visually symmetric at N visible content rows. + const shellStringCapHeight = + isCappingShell && shellCapHeight !== undefined + ? shellCapHeight + 1 + : availableHeight; const innerWidth = contentWidth - STATUS_INDICATOR_WIDTH; // Long tool call response in MarkdownDisplay doesn't respect availableTerminalHeight properly, @@ -420,13 +448,13 @@ export const ToolMessage: React.FC = ({ <> {effectiveDisplayRenderer.stats && ( )} @@ -435,7 +463,7 @@ export const ToolMessage: React.FC = ({ )} diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index a8bf45d7f..3abba87df 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -249,6 +249,11 @@ "description": "Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).", "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", + "default": 5 } } },