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
}
}
},