Merge pull request #2770 from chiga0/feat/add-verbose-mode-switcher

feat: to #2767, support verbose and compact mode swither with ctrl-o
This commit is contained in:
tanzhenxin 2026-04-07 15:48:41 +08:00 committed by GitHub
commit b632541629
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 438 additions and 112 deletions

View file

@ -16,6 +16,7 @@ import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useVerboseMode } from '../contexts/VerboseModeContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
@ -23,6 +24,7 @@ export const Footer: React.FC = () => {
const uiState = useUIState();
const config = useConfig();
const { vimEnabled, vimMode } = useVimMode();
const { verboseMode } = useVerboseMode();
const { promptTokenCount, showAutoAcceptIndicator } = {
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
@ -93,6 +95,12 @@ export const Footer: React.FC = () => {
),
});
}
if (verboseMode) {
rightItems.push({
key: 'verbose',
node: <Text color={theme.text.accent}>{t('verbose')}</Text>,
});
}
return (
<Box
justifyContent="space-between"

View file

@ -48,6 +48,7 @@ import { ContextUsage } from './views/ContextUsage.js';
import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js';
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
import { BtwMessage } from './messages/BtwMessage.js';
import { useVerboseMode } from '../contexts/VerboseModeContext.js';
interface HistoryItemDisplayProps {
item: HistoryItem;
@ -79,6 +80,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
? 0
: 1;
const { verboseMode } = useVerboseMode();
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
const contentWidth = terminalWidth - 4;
const boxWidth = mainAreaWidth || contentWidth;
@ -118,7 +120,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
contentWidth={contentWidth}
/>
)}
{itemForDisplay.type === 'gemini_thought' && (
{verboseMode && itemForDisplay.type === 'gemini_thought' && (
<ThinkMessage
text={itemForDisplay.text}
isPending={isPending}
@ -128,7 +130,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
contentWidth={contentWidth}
/>
)}
{itemForDisplay.type === 'gemini_thought_content' && (
{verboseMode && itemForDisplay.type === 'gemini_thought_content' && (
<ThinkMessageContent
text={itemForDisplay.text}
isPending={isPending}
@ -183,6 +185,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
isFocused={isFocused}
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
isUserInitiated={itemForDisplay.isUserInitiated}
/>
)}
{itemForDisplay.type === 'compression' && (

View file

@ -13,6 +13,7 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
import { DebugModeNotification } from './DebugModeNotification.js';
import { useVerboseMode } from '../contexts/VerboseModeContext.js';
// Limit Gemini messages to a very high number of lines to mitigate performance
// issues in the worst case if we somehow get an enormous response from Gemini.
@ -23,6 +24,7 @@ const MAX_GEMINI_MESSAGE_LINES = 65536;
export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
const { frozenSnapshot } = useVerboseMode();
const {
pendingHistoryItems,
terminalWidth,
@ -57,21 +59,26 @@ export const MainContent = () => {
</Static>
<OverflowProvider>
<Box flexDirection="column">
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={terminalWidth}
mainAreaWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
))}
{(frozenSnapshot ?? pendingHistoryItems).map((item, i) => {
const isFrozen = frozenSnapshot !== null;
return (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={terminalWidth}
mainAreaWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={isFrozen ? false : !uiState.isEditorDialogOpen}
activeShellPtyId={isFrozen ? undefined : uiState.activePtyId}
embeddedShellFocused={
isFrozen ? false : uiState.embeddedShellFocused
}
/>
);
})}
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>

View file

@ -1,5 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on narrow terminal > complete-footer-narrow 1`] = `" ? for shortcuts 0.1% used"`;
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on narrow terminal > complete-footer-narrow 1`] = `" ? for shortcuts 0.1% used | verbose"`;
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on wide terminal > complete-footer-wide 1`] = `" ? for shortcuts 0.1% context used"`;
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on wide terminal > complete-footer-wide 1`] = `" ? for shortcuts 0.1% context used | verbose"`;

View file

@ -0,0 +1,108 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
import { theme } from '../../semantic-colors.js';
import { t } from '../../../i18n/index.js';
import { ToolStatusIndicator } from '../shared/ToolStatusIndicator.js';
interface CompactToolGroupDisplayProps {
toolCalls: IndividualToolCallDisplay[];
contentWidth: number;
}
// Priority: Confirming > Executing > Error > Canceled > Pending > Success
function getOverallStatus(
toolCalls: IndividualToolCallDisplay[],
): ToolCallStatus {
if (toolCalls.some((t) => t.status === ToolCallStatus.Confirming))
return ToolCallStatus.Confirming;
if (toolCalls.some((t) => t.status === ToolCallStatus.Executing))
return ToolCallStatus.Executing;
if (toolCalls.some((t) => t.status === ToolCallStatus.Error))
return ToolCallStatus.Error;
if (toolCalls.some((t) => t.status === ToolCallStatus.Canceled))
return ToolCallStatus.Canceled;
if (toolCalls.some((t) => t.status === ToolCallStatus.Pending))
return ToolCallStatus.Pending;
return ToolCallStatus.Success;
}
// Active tool priority: Confirming > Executing > last in array
function getActiveTool(
toolCalls: IndividualToolCallDisplay[],
): IndividualToolCallDisplay {
return (
toolCalls.find((t) => t.status === ToolCallStatus.Confirming) ??
toolCalls.find((t) => t.status === ToolCallStatus.Executing) ??
toolCalls[toolCalls.length - 1]
);
}
export const CompactToolGroupDisplay: React.FC<
CompactToolGroupDisplayProps
> = ({ toolCalls, contentWidth }) => {
if (toolCalls.length === 0) return null;
const overallStatus = getOverallStatus(toolCalls);
const activeTool = getActiveTool(toolCalls);
const isShellCommand = toolCalls.some(
(t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME,
);
const hasPending = !toolCalls.every(
(t) => t.status === ToolCallStatus.Success,
);
const borderColor = isShellCommand
? theme.ui.symbol
: hasPending
? theme.status.warning
: theme.border.default;
// Take only the first line of description to prevent multi-line shell scripts
// from expanding the compact view (wrap="truncate-end" only handles width overflow,
// not literal \n characters in the content)
const activeToolDescription = activeTool.description
? activeTool.description.split('\n')[0]
: '';
return (
<Box
flexDirection="column"
borderStyle="round"
width={contentWidth}
borderDimColor={hasPending}
borderColor={borderColor}
gap={0}
>
{/* Status line: icon + tool name + description */}
<Box flexDirection="row">
<ToolStatusIndicator status={overallStatus} name={activeTool.name} />
<Box flexGrow={1}>
<Text wrap="truncate-end">
<Text bold>{activeTool.name}</Text>
{activeToolDescription ? (
<Text color={theme.text.secondary}>
{' '}
{activeToolDescription}
</Text>
) : null}
</Text>
</Box>
</Box>
{/* Hint line */}
<Text color={theme.text.secondary}>
{t('Press Ctrl+O to show full tool output')}
</Text>
</Box>
);
};

View file

@ -11,9 +11,11 @@ import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { CompactToolGroupDisplay } from './CompactToolGroupDisplay.js';
import { theme } from '../../semantic-colors.js';
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { useVerboseMode } from '../../contexts/VerboseModeContext.js';
interface ToolGroupMessageProps {
groupId: number;
@ -24,6 +26,7 @@ interface ToolGroupMessageProps {
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
onShellInputSubmit?: (input: string) => void;
isUserInitiated?: boolean;
}
// Main component renders the border and maps the tools using ToolMessage
@ -34,7 +37,15 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
isFocused = true,
activeShellPtyId,
embeddedShellFocused,
isUserInitiated,
}) => {
const config = useConfig();
const { verboseMode } = useVerboseMode();
const hasConfirmingTool = toolCalls.some(
(t) => t.status === ToolCallStatus.Confirming,
);
const hasErrorTool = toolCalls.some((t) => t.status === ToolCallStatus.Error);
const isEmbeddedShellFocused =
embeddedShellFocused &&
toolCalls.some(
@ -42,11 +53,36 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
t.ptyId === activeShellPtyId && t.status === ToolCallStatus.Executing,
);
// useMemo must be called unconditionally (Rules of Hooks) — before any early return
// only prompt for tool approval on the first 'confirming' tool in the list
const toolAwaitingApproval = useMemo(
() => toolCalls.find((tc) => tc.status === ToolCallStatus.Confirming),
[toolCalls],
);
// Compact mode: entire group → single line summary
// Force-expand when: user must interact (Confirming), tool errored,
// shell is focused, or user-initiated
const showCompact =
!verboseMode &&
!hasConfirmingTool &&
!hasErrorTool &&
!isEmbeddedShellFocused &&
!isUserInitiated;
if (showCompact) {
return (
<CompactToolGroupDisplay
toolCalls={toolCalls}
contentWidth={contentWidth}
/>
);
}
// Full expanded view
const hasPending = !toolCalls.every(
(t) => t.status === ToolCallStatus.Success,
);
const config = useConfig();
const isShellCommand = toolCalls.some(
(t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME,
);
@ -61,13 +97,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
// account for border (2 chars) and padding (2 chars)
const innerWidth = contentWidth - 4;
// only prompt for tool approval on the first 'confirming' tool in the list
// note, after the CTA, this automatically moves over to the next 'confirming' tool
const toolAwaitingApproval = useMemo(
() => toolCalls.find((tc) => tc.status === ToolCallStatus.Confirming),
[toolCalls],
);
let countToolCallsWithResults = 0;
for (const tool of toolCalls) {
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
@ -121,6 +150,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
config={config}
forceShowResult={
isUserInitiated ||
tool.status === ToolCallStatus.Confirming ||
tool.status === ToolCallStatus.Error
}
/>
</Box>
{tool.status === ToolCallStatus.Confirming &&

View file

@ -12,6 +12,7 @@ import { StreamingState, ToolCallStatus } from '../../types.js';
import { Text } from 'ink';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import { SettingsContext } from '../../contexts/SettingsContext.js';
import { VerboseModeProvider } from '../../contexts/VerboseModeContext.js';
import type {
AnsiOutput,
AnsiOutputDisplay,
@ -101,18 +102,21 @@ const mockSettings: LoadedSettings = {
},
} as LoadedSettings;
// Helper to render with context
// Helper to render with context (verbose=true by default to show tool output)
const renderWithContext = (
ui: React.ReactElement,
streamingState: StreamingState,
verboseMode = true,
) => {
const contextValue: StreamingState = streamingState;
return render(
<SettingsContext.Provider value={mockSettings}>
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>
</SettingsContext.Provider>,
<VerboseModeProvider value={{ verboseMode, frozenSnapshot: null }}>
<SettingsContext.Provider value={mockSettings}>
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>
</SettingsContext.Provider>
</VerboseModeProvider>,
);
};
@ -143,6 +147,18 @@ describe('<ToolMessage />', () => {
expect(output).toContain('MockMarkdown:Test result');
});
it('hides result output in compact mode (verboseMode=false)', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} />,
StreamingState.Idle,
false, // compact mode
);
const output = lastFrame();
expect(output).toContain('✓'); // status indicator still visible
expect(output).toContain('test-tool'); // tool name still visible
expect(output).not.toContain('MockMarkdown:Test result'); // result hidden
});
describe('ToolStatusIndicator rendering', () => {
it('shows ✓ for Success status', () => {
const { lastFrame } = renderWithContext(

View file

@ -11,7 +11,6 @@ import { ToolCallStatus } from '../../types.js';
import { DiffRenderer } from './DiffRenderer.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { AnsiOutputText } from '../AnsiOutput.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { TodoDisplay } from '../TodoDisplay.js';
import type {
@ -25,18 +24,19 @@ import type {
import { AgentExecutionDisplay } from '../subagents/index.js';
import { PlanSummaryDisplay } from '../PlanSummaryDisplay.js';
import { ShellInputPrompt } from '../ShellInputPrompt.js';
import {
SHELL_COMMAND_NAME,
SHELL_NAME,
TOOL_STATUS,
} from '../../constants.js';
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../../config/settings.js';
import { useVerboseMode } from '../../contexts/VerboseModeContext.js';
import {
ToolStatusIndicator,
STATUS_INDICATOR_WIDTH,
} from '../shared/ToolStatusIndicator.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
const STATUS_INDICATOR_WIDTH = 3;
const MIN_LINES_SHOWN = 2; // show at least this many lines
// Large threshold to ensure we don't cause performance issues for very large
@ -248,6 +248,7 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
config?: Config;
forceShowResult?: boolean;
}
export const ToolMessage: React.FC<ToolMessageProps> = ({
@ -263,10 +264,11 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
embeddedShellFocused,
ptyId,
config,
forceShowResult,
}) => {
const settings = useSettings();
const isThisShellFocused =
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
(name === SHELL_COMMAND_NAME || name === SHELL_NAME) &&
status === ToolCallStatus.Executing &&
ptyId === activeShellPtyId &&
embeddedShellFocused;
@ -300,7 +302,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
}, [isThisShellFocused]);
const isThisShellFocusable =
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
(name === SHELL_COMMAND_NAME || name === SHELL_NAME) &&
status === ToolCallStatus.Executing &&
config?.getShouldUseNodePtyShell();
@ -324,6 +326,11 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
// Use the custom hook to determine the display type
const displayRenderer = useResultDisplayRenderer(resultDisplay);
const { verboseMode } = useVerboseMode();
const effectiveDisplayRenderer =
verboseMode || forceShowResult
? displayRenderer
: { type: 'none' as const };
return (
<Box paddingX={1} paddingY={0} flexDirection="column">
@ -344,44 +351,44 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
)}
{emphasis === 'high' && <TrailingIndicator />}
</Box>
{displayRenderer.type !== 'none' && (
{effectiveDisplayRenderer.type !== 'none' && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}>
<Box flexDirection="column">
{displayRenderer.type === 'todo' && (
<TodoResultRenderer data={displayRenderer.data} />
{effectiveDisplayRenderer.type === 'todo' && (
<TodoResultRenderer data={effectiveDisplayRenderer.data} />
)}
{displayRenderer.type === 'plan' && (
{effectiveDisplayRenderer.type === 'plan' && (
<PlanResultRenderer
data={displayRenderer.data}
data={effectiveDisplayRenderer.data}
availableHeight={availableHeight}
childWidth={innerWidth}
/>
)}
{displayRenderer.type === 'task' && config && (
{effectiveDisplayRenderer.type === 'task' && config && (
<SubagentExecutionRenderer
data={displayRenderer.data}
data={effectiveDisplayRenderer.data}
availableHeight={availableHeight}
childWidth={innerWidth}
config={config}
/>
)}
{displayRenderer.type === 'diff' && (
{effectiveDisplayRenderer.type === 'diff' && (
<DiffResultRenderer
data={displayRenderer.data}
data={effectiveDisplayRenderer.data}
availableHeight={availableHeight}
childWidth={innerWidth}
settings={settings}
/>
)}
{displayRenderer.type === 'ansi' && (
{effectiveDisplayRenderer.type === 'ansi' && (
<AnsiOutputText
data={displayRenderer.data}
data={effectiveDisplayRenderer.data}
availableTerminalHeight={availableHeight}
/>
)}
{displayRenderer.type === 'string' && (
{effectiveDisplayRenderer.type === 'string' && (
<StringResultRenderer
data={displayRenderer.data}
data={effectiveDisplayRenderer.data}
renderAsMarkdown={renderOutputAsMarkdown}
availableHeight={availableHeight}
childWidth={innerWidth}
@ -402,53 +409,6 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
);
};
type ToolStatusIndicatorProps = {
status: ToolCallStatus;
name: string;
};
const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
status,
name,
}) => {
const isShell = name === SHELL_COMMAND_NAME || name === SHELL_NAME;
const statusColor = isShell ? theme.ui.symbol : theme.status.warning;
return (
<Box minWidth={STATUS_INDICATOR_WIDTH}>
{status === ToolCallStatus.Pending && (
<Text color={theme.status.success}>{TOOL_STATUS.PENDING}</Text>
)}
{status === ToolCallStatus.Executing && (
<GeminiRespondingSpinner
spinnerType="toggle"
nonRespondingDisplay={TOOL_STATUS.EXECUTING}
/>
)}
{status === ToolCallStatus.Success && (
<Text color={theme.status.success} aria-label={'Success:'}>
{TOOL_STATUS.SUCCESS}
</Text>
)}
{status === ToolCallStatus.Confirming && (
<Text color={statusColor} aria-label={'Confirming:'}>
{TOOL_STATUS.CONFIRMING}
</Text>
)}
{status === ToolCallStatus.Canceled && (
<Text color={statusColor} aria-label={'Canceled:'} bold>
{TOOL_STATUS.CANCELED}
</Text>
)}
{status === ToolCallStatus.Error && (
<Text color={theme.status.error} aria-label={'Error:'} bold>
{TOOL_STATUS.ERROR}
</Text>
)}
</Box>
);
};
type ToolInfo = {
name: string;
description: string;

View file

@ -0,0 +1,65 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { ToolCallStatus } from '../../types.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
import {
TOOL_STATUS,
SHELL_COMMAND_NAME,
SHELL_NAME,
} from '../../constants.js';
import { theme } from '../../semantic-colors.js';
export const STATUS_INDICATOR_WIDTH = 3;
type ToolStatusIndicatorProps = {
status: ToolCallStatus;
name: string;
};
export const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
status,
name,
}) => {
const isShell = name === SHELL_COMMAND_NAME || name === SHELL_NAME;
const statusColor = isShell ? theme.ui.symbol : theme.status.warning;
return (
<Box minWidth={STATUS_INDICATOR_WIDTH}>
{status === ToolCallStatus.Pending && (
<Text color={theme.status.success}>{TOOL_STATUS.PENDING}</Text>
)}
{status === ToolCallStatus.Executing && (
<GeminiRespondingSpinner
spinnerType="toggle"
nonRespondingDisplay={TOOL_STATUS.EXECUTING}
/>
)}
{status === ToolCallStatus.Success && (
<Text color={theme.status.success} aria-label={'Success:'}>
{TOOL_STATUS.SUCCESS}
</Text>
)}
{status === ToolCallStatus.Confirming && (
<Text color={statusColor} aria-label={'Confirming:'}>
{TOOL_STATUS.CONFIRMING}
</Text>
)}
{status === ToolCallStatus.Canceled && (
<Text color={statusColor} aria-label={'Canceled:'} bold>
{TOOL_STATUS.CANCELED}
</Text>
)}
{status === ToolCallStatus.Error && (
<Text color={theme.status.error} aria-label={'Error:'} bold>
{TOOL_STATUS.ERROR}
</Text>
)}
</Box>
);
};