mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
* fix(cli): remove residual blank lines after MCP init completes (#3095) ConfigInitDisplay rendered <Box marginTop={1}> plus a content line, so the live area grew by 2 rows during startup. When initialization finished and the component unmounted, Ink shrank the live area but the rows it had already committed to the terminal scrollback cannot be reclaimed, leaving a visible gap above the input. Move the MCP init status into the Footer's left-bottom status slot (always mounted, fixed height) so the live area height stays constant across the init → ready transition. The status participates in the existing priority chain: ctrlC / ctrlD / escape / vim / shell / autoAccept / configInit / hint. * fix(cli): suppress MCP init message when custom status line is active Audit follow-up. Previously the configInit branch preceded the suppressHint branch in the footer's left-bottom priority chain. With a custom status line configured, <Text>{null}</Text> collapses to zero rows in Ink, so the footer's bottom row went from 1 row during init to 0 rows after — a 1-row height oscillation that reintroduces the same scrollback-residue symptom the original fix eliminated in the default case. Swap the order so suppressHint short-circuits to null first: the init message now shares the hint's suppression rule, keeping the footer's height constant in every configuration. Also: - Gate the hook's return on isConfigInitialized directly instead of letting the effect clear state, avoiding a one-frame flash where the stale "Initializing..." message leaks through on the first render after init completes. - Cover the new behavior with three Footer tests, including a regression test for the custom-status-line case. * fix(cli): show MCP init progress even under a custom status line Reverting a UX trade-off introduced in the previous commit. That change suppressed the init message whenever a custom status line was active, arguing that <Text>{null}</Text> collapses to zero rows in Ink and any non-zero init row would re-create a one-row shrink on completion. Zero shrink was the wrong goal. Hiding init progress from users who have configured a status line is a real usability loss — the status line does not surface MCP connection state, so those users now see no feedback during startup. A one-time, one-line shrink on init completion is a far smaller regression than the original two-row scrollback residue this PR was created to fix, and strictly better than the silent alternative. Keep the init message in the left-bottom slot and let it sit above suppressHint in the priority chain. Update the regression test so that it pins the new behavior (init is visible with or without a status line) and prevents the suppression from being reintroduced. * fix(cli): keep MCP init progress visible in screen-reader mode Footer is gated behind !isScreenReaderEnabled, so moving the init message inside Footer silenced it for screen-reader users. Render the same message as a plain Text node in Composer when the screen reader is active — screen-reader users don't suffer from the live-area residual row issue that motivated the original move, so an independent node is safe for them. * refactor(cli): drop duplicated screen-reader init path and show progress under YOLO - ScreenReaderAppLayout already mounts <Footer /> directly, so the separate <Text> branch in Composer was producing a duplicated 'Connecting to MCP servers...' line in screen-reader mode. Remove it. - Move configInitMessage ahead of AutoAcceptIndicator in the footer's priority chain so users launched with YOLO / auto-accept-edits still see the ~1s startup progress; the approval-mode indicator takes over as soon as init finishes. - Add unit tests for useConfigInitMessage covering the idle, progress, reset, and unsubscribe paths.
152 lines
5.6 KiB
TypeScript
152 lines
5.6 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { Box, useIsScreenReaderEnabled } from 'ink';
|
|
import { useCallback, useState } from 'react';
|
|
import { LoadingIndicator } from './LoadingIndicator.js';
|
|
import { InputPrompt } from './InputPrompt.js';
|
|
import { Footer } from './Footer.js';
|
|
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
|
|
import { KeyboardShortcuts } from './KeyboardShortcuts.js';
|
|
import { useUIState } from '../contexts/UIStateContext.js';
|
|
import { useUIActions } from '../contexts/UIActionsContext.js';
|
|
import { useVimMode } from '../contexts/VimModeContext.js';
|
|
import { useConfig } from '../contexts/ConfigContext.js';
|
|
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
|
|
import { FeedbackDialog } from '../FeedbackDialog.js';
|
|
import { t } from '../../i18n/index.js';
|
|
|
|
export const Composer = () => {
|
|
const config = useConfig();
|
|
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
|
const uiState = useUIState();
|
|
const uiActions = useUIActions();
|
|
const { vimEnabled } = useVimMode();
|
|
|
|
const {
|
|
showAutoAcceptIndicator,
|
|
streamingResponseLengthRef,
|
|
isReceivingContent,
|
|
} = uiState;
|
|
|
|
// Real-time token animation is performed inside LoadingIndicator itself, so
|
|
// the 100ms polling only re-renders that one component — keeping InputPrompt
|
|
// and Footer static avoids terminal flicker during streaming.
|
|
const isStreaming =
|
|
uiState.streamingState === StreamingState.Responding ||
|
|
uiState.streamingState === StreamingState.WaitingForConfirmation;
|
|
|
|
// Aggregate agent tool tokens from executing tool calls. Only changes when
|
|
// a subagent reports progress, so it doesn't drive the animation loop.
|
|
let agentTokens = 0;
|
|
for (const item of uiState.pendingGeminiHistoryItems ?? []) {
|
|
if (item.type === 'tool_group') {
|
|
const toolGroup = item as HistoryItemToolGroup;
|
|
for (const tool of toolGroup.tools) {
|
|
const display = tool.resultDisplay;
|
|
if (
|
|
typeof display === 'object' &&
|
|
display !== null &&
|
|
'type' in display &&
|
|
display.type === 'task_execution' &&
|
|
'tokenCount' in display &&
|
|
typeof display.tokenCount === 'number'
|
|
) {
|
|
agentTokens += display.tokenCount;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// State for keyboard shortcuts display toggle
|
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
const handleToggleShortcuts = useCallback(() => {
|
|
setShowShortcuts((prev) => !prev);
|
|
}, []);
|
|
|
|
// State for suggestions visibility
|
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
const handleSuggestionsVisibilityChange = useCallback(
|
|
(visible: boolean) => {
|
|
setShowSuggestions(visible);
|
|
// Also notify AppContainer for Tab key handling
|
|
uiActions.onSuggestionsVisibilityChange(visible);
|
|
},
|
|
[uiActions],
|
|
);
|
|
|
|
return (
|
|
<Box flexDirection="column" marginTop={1}>
|
|
{!uiState.embeddedShellFocused && (
|
|
<LoadingIndicator
|
|
// Hide loading phrases when enableLoadingPhrases is explicitly false.
|
|
// Using === false ensures phrases show by default when undefined.
|
|
thought={
|
|
uiState.streamingState === StreamingState.WaitingForConfirmation ||
|
|
config.getAccessibility()?.enableLoadingPhrases === false
|
|
? undefined
|
|
: uiState.thought
|
|
}
|
|
currentLoadingPhrase={
|
|
config.getAccessibility()?.enableLoadingPhrases === false
|
|
? undefined
|
|
: uiState.currentLoadingPhrase
|
|
}
|
|
elapsedTime={uiState.elapsedTime}
|
|
candidatesTokens={agentTokens}
|
|
streamingCharsRef={streamingResponseLengthRef}
|
|
isStreaming={isStreaming}
|
|
isReceivingContent={isReceivingContent}
|
|
/>
|
|
)}
|
|
|
|
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
|
|
|
|
{uiState.isFeedbackDialogOpen && <FeedbackDialog />}
|
|
|
|
{uiState.isInputActive && (
|
|
<InputPrompt
|
|
buffer={uiState.buffer}
|
|
inputWidth={uiState.inputWidth}
|
|
suggestionsWidth={uiState.suggestionsWidth}
|
|
onSubmit={uiActions.handleFinalSubmit}
|
|
userMessages={uiState.userMessages}
|
|
onClearScreen={uiActions.handleClearScreen}
|
|
config={config}
|
|
slashCommands={uiState.slashCommands}
|
|
commandContext={uiState.commandContext}
|
|
shellModeActive={uiState.shellModeActive}
|
|
setShellModeActive={uiActions.setShellModeActive}
|
|
approvalMode={showAutoAcceptIndicator}
|
|
onEscapePromptChange={uiActions.onEscapePromptChange}
|
|
onToggleShortcuts={handleToggleShortcuts}
|
|
showShortcuts={showShortcuts}
|
|
onSuggestionsVisibilityChange={handleSuggestionsVisibilityChange}
|
|
focus={true}
|
|
vimHandleInput={uiActions.vimHandleInput}
|
|
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
|
placeholder={
|
|
vimEnabled
|
|
? ' ' + t("Press 'i' for INSERT mode and 'Esc' for NORMAL mode.")
|
|
: ' ' + t('Type your message or @path/to/file')
|
|
}
|
|
promptSuggestion={uiState.promptSuggestion}
|
|
onPromptSuggestionDismiss={uiState.dismissPromptSuggestion}
|
|
/>
|
|
)}
|
|
|
|
{/* Exclusive area: only one component visible at a time */}
|
|
{/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */}
|
|
{uiState.isInputActive &&
|
|
!showSuggestions &&
|
|
(showShortcuts ? (
|
|
<KeyboardShortcuts />
|
|
) : (
|
|
!isScreenReaderEnabled && <Footer />
|
|
))}
|
|
</Box>
|
|
);
|
|
};
|