Merge branch 'main' into feat/ask-user-question-tool

This commit is contained in:
tanzhenxin 2026-03-06 15:03:10 +08:00
commit b346622ec2
51 changed files with 630 additions and 921 deletions

View file

@ -8,19 +8,23 @@ import type React from 'react';
import { useMemo } from 'react';
import { escapeAnsiCtrlCodes } from '../utils/textUtils.js';
import type { HistoryItem } from '../types.js';
import { UserMessage } from './messages/UserMessage.js';
import { UserShellMessage } from './messages/UserShellMessage.js';
import { GeminiMessage } from './messages/GeminiMessage.js';
import { InfoMessage } from './messages/InfoMessage.js';
import { ErrorMessage } from './messages/ErrorMessage.js';
import {
UserMessage,
UserShellMessage,
AssistantMessage,
AssistantMessageContent,
ThinkMessage,
ThinkMessageContent,
} from './messages/ConversationMessages.js';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
import { GeminiThoughtMessage } from './messages/GeminiThoughtMessage.js';
import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageContent.js';
import { CompressionMessage } from './messages/CompressionMessage.js';
import { SummaryMessage } from './messages/SummaryMessage.js';
import { WarningMessage } from './messages/WarningMessage.js';
import { RetryCountdownMessage } from './messages/RetryCountdownMessage.js';
import {
InfoMessage,
WarningMessage,
ErrorMessage,
RetryCountdownMessage,
} from './messages/StatusMessages.js';
import { Box } from 'ink';
import { AboutBox } from './AboutBox.js';
import { StatsDisplay } from './StatsDisplay.js';
@ -61,6 +65,11 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
embeddedShellFocused,
availableTerminalHeightGemini,
}) => {
const marginTop =
item.type === 'gemini_content' || item.type === 'gemini_thought_content'
? 0
: 1;
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
const contentWidth = terminalWidth - 4;
const boxWidth = mainAreaWidth || contentWidth;
@ -69,6 +78,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
<Box
flexDirection="column"
key={itemForDisplay.id}
marginTop={marginTop}
marginLeft={2}
marginRight={2}
>
@ -80,7 +90,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
<UserShellMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'gemini' && (
<GeminiMessage
<AssistantMessage
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
@ -90,7 +100,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
/>
)}
{itemForDisplay.type === 'gemini_content' && (
<GeminiMessageContent
<AssistantMessageContent
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
@ -100,7 +110,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
/>
)}
{itemForDisplay.type === 'gemini_thought' && (
<GeminiThoughtMessage
<ThinkMessage
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
@ -110,7 +120,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
/>
)}
{itemForDisplay.type === 'gemini_thought_content' && (
<GeminiThoughtMessageContent
<ThinkMessageContent
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={

View file

@ -1,7 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<HistoryItemDisplay /> > should render a full gemini item when using availableTerminalHeightGemini 1`] = `
" ✦ Example code block:
"
✦ Example code block:
1 Line 1
2 Line 2
3 Line 3
@ -109,7 +110,8 @@ exports[`<HistoryItemDisplay /> > should render a full gemini_content item when
`;
exports[`<HistoryItemDisplay /> > should render a truncated gemini item 1`] = `
" ✦ Example code block:
"
✦ Example code block:
... first 41 lines hidden ...
42 Line 42
43 Line 43

View file

@ -0,0 +1,261 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import stringWidth from 'string-width';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
import {
SCREEN_READER_MODEL_PREFIX,
SCREEN_READER_USER_PREFIX,
} from '../../textConstants.js';
interface UserMessageProps {
text: string;
}
interface UserShellMessageProps {
text: string;
}
interface AssistantMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
interface AssistantMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
interface ThinkMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
interface ThinkMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
interface PrefixedTextMessageProps {
text: string;
prefix: string;
prefixColor: string;
textColor: string;
ariaLabel?: string;
marginTop?: number;
alignSelf?: 'auto' | 'flex-start' | 'center' | 'flex-end';
}
interface PrefixedMarkdownMessageProps {
text: string;
prefix: string;
prefixColor: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
ariaLabel?: string;
textColor?: string;
}
interface ContinuationMarkdownMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
basePrefix: string;
textColor?: string;
}
function getPrefixWidth(prefix: string): number {
// Reserve one extra column so text never touches the prefix glyph.
return stringWidth(prefix) + 1;
}
const PrefixedTextMessage: React.FC<PrefixedTextMessageProps> = ({
text,
prefix,
prefixColor,
textColor,
ariaLabel,
marginTop = 0,
alignSelf,
}) => {
const prefixWidth = getPrefixWidth(prefix);
return (
<Box
flexDirection="row"
paddingY={0}
marginTop={marginTop}
alignSelf={alignSelf}
>
<Box width={prefixWidth}>
<Text color={prefixColor} aria-label={ariaLabel}>
{prefix}
</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={textColor}>
{text}
</Text>
</Box>
</Box>
);
};
const PrefixedMarkdownMessage: React.FC<PrefixedMarkdownMessageProps> = ({
text,
prefix,
prefixColor,
isPending,
availableTerminalHeight,
contentWidth,
ariaLabel,
textColor,
}) => {
const prefixWidth = getPrefixWidth(prefix);
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={prefixColor} aria-label={ariaLabel}>
{prefix}
</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={textColor}
/>
</Box>
</Box>
);
};
const ContinuationMarkdownMessage: React.FC<
ContinuationMarkdownMessageProps
> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
basePrefix,
textColor,
}) => {
const prefixWidth = getPrefixWidth(basePrefix);
return (
<Box flexDirection="column" paddingLeft={prefixWidth}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={textColor}
/>
</Box>
);
};
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => (
<PrefixedTextMessage
text={text}
prefix=">"
prefixColor={theme.text.accent}
textColor={theme.text.accent}
ariaLabel={SCREEN_READER_USER_PREFIX}
alignSelf="flex-start"
/>
);
export const UserShellMessage: React.FC<UserShellMessageProps> = ({ text }) => {
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
return (
<PrefixedTextMessage
text={commandToDisplay}
prefix="$"
prefixColor={theme.text.link}
textColor={theme.text.primary}
/>
);
};
export const AssistantMessage: React.FC<AssistantMessageProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => (
<PrefixedMarkdownMessage
text={text}
prefix="✦"
prefixColor={theme.text.accent}
ariaLabel={SCREEN_READER_MODEL_PREFIX}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
/>
);
export const AssistantMessageContent: React.FC<
AssistantMessageContentProps
> = ({ text, isPending, availableTerminalHeight, contentWidth }) => (
<ContinuationMarkdownMessage
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
basePrefix="✦"
/>
);
export const ThinkMessage: React.FC<ThinkMessageProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => (
<PrefixedMarkdownMessage
text={text}
prefix="✦"
prefixColor={theme.text.secondary}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
textColor={theme.text.secondary}
/>
);
export const ThinkMessageContent: React.FC<ThinkMessageContentProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => (
<ContinuationMarkdownMessage
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
basePrefix="✦"
textColor={theme.text.secondary}
/>
);

View file

@ -1,38 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
interface ErrorMessageProps {
text: string;
/** Optional inline hint displayed after the error text in secondary/dimmed color */
hint?: string;
}
/**
* Renders an error message with a "✕" prefix.
* When a hint is provided (e.g., retry countdown), it is displayed inline
* in parentheses with a dimmed secondary color, similar to the ESC hint
* style used in LoadingIndicator.
*/
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text, hint }) => {
const prefix = '✕ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.status.error}>{prefix}</Text>
</Box>
<Box flexGrow={1} flexWrap="wrap" flexDirection="row">
<Text color={theme.status.error}>{text}</Text>
{hint && <Text color={theme.text.secondary}> ({hint})</Text>}
</Box>
</Box>
);
};

View file

@ -1,46 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
interface GeminiMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => {
const prefix = '✦ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.accent} aria-label={SCREEN_READER_MODEL_PREFIX}>
{prefix}
</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
/>
</Box>
</Box>
);
};

View file

@ -1,43 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
interface GeminiMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
/*
* Gemini message content is a semi-hacked component. The intention is to represent a partial
* of GeminiMessage and is only used when a response gets too long. In that instance messages
* are split into multiple GeminiMessageContent's to enable the root <Static> component in
* App.tsx to be as performant as humanly possible.
*/
export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => {
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
return (
<Box flexDirection="column" paddingLeft={prefixWidth}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
/>
</Box>
);
};

View file

@ -1,48 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
interface GeminiThoughtMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
/**
* Displays model thinking/reasoning text with a softer, dimmed style
* to visually distinguish it from regular content output.
*/
export const GeminiThoughtMessage: React.FC<GeminiThoughtMessageProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => {
const prefix = '✦ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.secondary}>{prefix}</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={theme.text.secondary}
/>
</Box>
</Box>
);
};

View file

@ -1,40 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
interface GeminiThoughtMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
/**
* Continuation component for thought messages, similar to GeminiMessageContent.
* Used when a thought response gets too long and needs to be split for performance.
*/
export const GeminiThoughtMessageContent: React.FC<
GeminiThoughtMessageContentProps
> = ({ text, isPending, availableTerminalHeight, contentWidth }) => {
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
return (
<Box flexDirection="column" paddingLeft={prefixWidth}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={theme.text.secondary}
/>
</Box>
);
};

View file

@ -1,37 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface InfoMessageProps {
text: string;
}
export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
// Don't render anything if text is empty
if (!text || text.trim() === '') {
return null;
}
const prefix = ' ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.status.warning}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={theme.status.warning}>
<RenderInline text={text} textColor={theme.status.warning} />
</Text>
</Box>
</Box>
);
};

View file

@ -1,41 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
interface RetryCountdownMessageProps {
text: string;
}
/**
* Displays a retry countdown message in a dimmed/secondary style
* to visually distinguish it from error messages.
*/
export const RetryCountdownMessage: React.FC<RetryCountdownMessageProps> = ({
text,
}) => {
if (!text || text.trim() === '') {
return null;
}
const prefix = '↻ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.secondary}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={theme.text.secondary}>
{text}
</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,105 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import stringWidth from 'string-width';
import { theme } from '../../semantic-colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface StatusMessageProps {
text: string;
prefix: string;
prefixColor: string;
textColor: string;
children?: React.ReactNode;
}
interface StatusTextProps {
text: string;
}
/**
* Shared renderer for status-like history messages (info/warning/error/retry).
* Keeps prefix spacing and wrapping behavior consistent across variants.
*/
export const StatusMessage: React.FC<StatusMessageProps> = ({
text,
prefix,
prefixColor,
textColor,
children,
}) => {
if (!text || text.trim() === '') {
return null;
}
const prefixWidth = stringWidth(prefix) + 1;
return (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text color={prefixColor}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={textColor}>
<RenderInline text={text} />
{children}
</Text>
</Box>
</Box>
);
};
export const InfoMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix="●"
prefixColor={theme.text.primary}
textColor={theme.text.primary}
/>
);
export const SuccessMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix="✓"
prefixColor={theme.status.success}
textColor={theme.status.success}
/>
);
export const WarningMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix="⚠"
prefixColor={theme.status.warning}
textColor={theme.status.warning}
/>
);
export const ErrorMessage: React.FC<StatusTextProps & { hint?: string }> = ({
text,
hint,
}) => (
<StatusMessage
text={text}
prefix="✕"
prefixColor={theme.status.error}
textColor={theme.status.error}
>
{hint && <Text color={theme.text.secondary}> ({hint})</Text>}
</StatusMessage>
);
export const RetryCountdownMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix="↻"
prefixColor={theme.text.secondary}
textColor={theme.text.secondary}
/>
);

View file

@ -1,38 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_USER_PREFIX } from '../../textConstants.js';
import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.js';
interface UserMessageProps {
text: string;
}
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
const prefix = '> ';
const prefixWidth = prefix.length;
const isSlashCommand = checkIsSlashCommand(text);
const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary;
return (
<Box flexDirection="row" paddingY={0} marginY={1} alignSelf="flex-start">
<Box width={prefixWidth}>
<Text color={theme.text.accent} aria-label={SCREEN_READER_USER_PREFIX}>
{prefix}
</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={textColor}>
{text}
</Text>
</Box>
</Box>
);
};

View file

@ -1,25 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
interface UserShellMessageProps {
text: string;
}
export const UserShellMessage: React.FC<UserShellMessageProps> = ({ text }) => {
// Remove leading '!' if present, as App.tsx adds it for the processor.
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
return (
<Box>
<Text color={theme.text.link}>$ </Text>
<Text color={theme.text.primary}>{commandToDisplay}</Text>
</Box>
);
};

View file

@ -1,33 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import { theme } from '../../semantic-colors.js';
interface WarningMessageProps {
text: string;
}
export const WarningMessage: React.FC<WarningMessageProps> = ({ text }) => {
const prefix = '⚠ ';
const prefixWidth = 3;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={Colors.AccentYellow}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={Colors.AccentYellow}>
<RenderInline text={text} textColor={theme.status.warning} />
</Text>
</Box>
</Box>
);
};