fix: terminal flicker when subagent is executing

This commit is contained in:
tanzhenxin
2025-09-17 17:01:06 +08:00
parent 5f90472a7d
commit e552bc9609
5 changed files with 120 additions and 27 deletions

View File

@@ -723,8 +723,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
pendingHistoryItems.push(...pendingGeminiHistoryItems);
const pendingHistoryItems = useMemo(() => {
const items = [...pendingSlashCommandHistoryItems];
items.push(...pendingGeminiHistoryItems);
return items.map((item, i) => ({ ...item, id: i }));
}, [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems]);
const { elapsedTime, currentLoadingPhrase } =
useLoadingIndicator(streamingState);
@@ -1070,16 +1073,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Static>
<OverflowProvider>
<Box ref={pendingHistoryItemRef} flexDirection="column">
{pendingHistoryItems.map((item, i) => (
{pendingHistoryItems.map((item) => (
<HistoryItemDisplay
key={i}
key={item.id}
availableTerminalHeight={
constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
// TODO(taehykim): It seems like references to ids aren't necessary in
// HistoryItemDisplay. Refactor later. Use a fake id for now.
item={{ ...item, id: 0 }}
item={item}
isPending={true}
config={config}
isFocused={!isEditorDialogOpen}

View File

@@ -35,7 +35,7 @@ interface HistoryItemDisplayProps {
commands?: readonly SlashCommand[];
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
item,
availableTerminalHeight,
terminalWidth,
@@ -101,3 +101,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{item.type === 'summary' && <SummaryMessage summary={item.summary} />}
</Box>
);
HistoryItemDisplayComponent.displayName = 'HistoryItemDisplay';
export const HistoryItemDisplay = React.memo(HistoryItemDisplayComponent);

View File

@@ -29,6 +29,7 @@ export interface ToolConfirmationMessageProps {
isFocused?: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
compactMode?: boolean;
}
export const ToolConfirmationMessage: React.FC<
@@ -39,6 +40,7 @@ export const ToolConfirmationMessage: React.FC<
isFocused = true,
availableTerminalHeight,
terminalWidth,
compactMode = false,
}) => {
const { onConfirm } = confirmationDetails;
const childWidth = terminalWidth - 2; // 2 for padding
@@ -70,6 +72,40 @@ export const ToolConfirmationMessage: React.FC<
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
// Compact mode: return simple 3-option display
if (compactMode) {
const compactOptions: Array<RadioSelectItem<ToolConfirmationOutcome>> = [
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
label: 'Allow always',
value: ToolConfirmationOutcome.ProceedAlways,
},
{
label: 'No',
value: ToolConfirmationOutcome.Cancel,
},
];
return (
<Box flexDirection="column">
<Box>
<Text wrap="truncate">Do you want to proceed?</Text>
</Box>
<Box>
<RadioButtonSelect
items={compactOptions}
onSelect={handleSelect}
isFocused={isFocused}
/>
</Box>
</Box>
);
}
// Original logic continues unchanged below
let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here
let question: string;

View File

@@ -17,12 +17,12 @@ import { COLOR_OPTIONS } from '../constants.js';
import { fmtDuration } from '../utils.js';
import { ToolConfirmationMessage } from '../../messages/ToolConfirmationMessage.js';
export type DisplayMode = 'default' | 'verbose';
export type DisplayMode = 'compact' | 'default' | 'verbose';
export interface AgentExecutionDisplayProps {
data: TaskResultDisplay;
availableHeight?: number;
childWidth?: number;
childWidth: number;
}
const getStatusColor = (
@@ -76,8 +76,8 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
data,
availableHeight,
childWidth,
}) => {
const [displayMode, setDisplayMode] = React.useState<DisplayMode>('default');
}: AgentExecutionDisplayProps) => {
const [displayMode, setDisplayMode] = React.useState<DisplayMode>('compact');
const agentColor = useMemo(() => {
const colorOption = COLOR_OPTIONS.find(
@@ -90,8 +90,6 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
// This component only listens to keyboard shortcut events when the subagent is running
if (data.status !== 'running') return '';
if (displayMode === 'verbose') return 'Press ctrl+r to show less.';
if (displayMode === 'default') {
const hasMoreLines =
data.taskPrompt.split('\n').length > MAX_TASK_PROMPT_LINES;
@@ -99,17 +97,28 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
data.toolCalls && data.toolCalls.length > MAX_TOOL_CALLS;
if (hasMoreToolCalls || hasMoreLines) {
return 'Press ctrl+r to show more.';
return 'Press ctrl+r to show less, ctrl+e to show more.';
}
return '';
return 'Press ctrl+r to show less.';
}
return '';
}, [displayMode, data.toolCalls, data.taskPrompt, data.status]);
// Handle ctrl+r keypresses to control display mode
if (displayMode === 'verbose') {
return 'Press ctrl+e to show less.';
}
return '';
}, [displayMode, data]);
// Handle keyboard shortcuts to control display mode
useKeypress(
(key) => {
if (key.ctrl && key.name === 'r') {
// ctrl+r toggles between compact and default
setDisplayMode((current) =>
current === 'compact' ? 'default' : 'compact',
);
} else if (key.ctrl && key.name === 'e') {
// ctrl+e toggles between default and verbose
setDisplayMode((current) =>
current === 'default' ? 'verbose' : 'default',
);
@@ -118,6 +127,44 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
{ isActive: true },
);
if (displayMode === 'compact') {
return (
<Box flexDirection="column">
{data.toolCalls && data.toolCalls.length > 0 && (
<Box flexDirection="column">
<ToolCallItem
toolCall={data.toolCalls[data.toolCalls.length - 1]}
compact={true}
/>
{/* Show count of additional tool calls if there are more than 1 */}
{data.toolCalls.length > 1 && !data.pendingConfirmation && (
<Box flexDirection="row" paddingLeft={4}>
<Text color={Colors.Gray}>
+{data.toolCalls.length - 1} more tool calls{' '}
{data.status === 'running' ? '(ctrl+r to expand)' : ''}
</Text>
</Box>
)}
</Box>
)}
{/* Inline approval prompt when awaiting confirmation */}
{data.pendingConfirmation && (
<Box flexDirection="column" marginTop={1} paddingLeft={1}>
<ToolConfirmationMessage
confirmationDetails={data.pendingConfirmation}
isFocused={true}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
compactMode={true}
/>
</Box>
)}
</Box>
);
}
// Default and verbose modes use normal layout
return (
<Box flexDirection="column" paddingX={1} gap={1}>
{/* Header with subagent name and status */}
@@ -154,7 +201,8 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
confirmationDetails={data.pendingConfirmation}
isFocused={true}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth ?? 80}
terminalWidth={childWidth}
compactMode={true}
/>
</Box>
)}
@@ -276,7 +324,8 @@ const ToolCallItem: React.FC<{
resultDisplay?: string;
description?: string;
};
}> = ({ toolCall }) => {
compact?: boolean;
}> = ({ toolCall, compact = false }) => {
const STATUS_INDICATOR_WIDTH = 3;
// Map subagent status to ToolCallStatus-like display
@@ -331,8 +380,8 @@ const ToolCallItem: React.FC<{
</Text>
</Box>
{/* Second line: truncated returnDisplay output */}
{truncatedOutput && (
{/* Second line: truncated returnDisplay output - hidden in compact mode */}
{!compact && truncatedOutput && (
<Box flexDirection="row" paddingLeft={STATUS_INDICATOR_WIDTH}>
<Text color={Colors.Gray}>{truncatedOutput}</Text>
</Box>

View File

@@ -901,10 +901,13 @@ export const useGeminiStream = (
],
);
const pendingHistoryItems = [
pendingHistoryItemRef.current,
pendingToolCallGroupDisplay,
].filter((i) => i !== undefined && i !== null);
const pendingHistoryItems = useMemo(
() =>
[pendingHistoryItemRef.current, pendingToolCallGroupDisplay].filter(
(i) => i !== undefined && i !== null,
),
[pendingHistoryItemRef, pendingToolCallGroupDisplay],
);
useEffect(() => {
const saveRestorableToolCalls = async () => {