Merge branch 'main' into feat/shell-pty-default-and-enhancements

This commit is contained in:
tanzhenxin 2026-03-06 14:36:18 +08:00
commit ca3a2be2ec
130 changed files with 16089 additions and 2249 deletions

View file

@ -0,0 +1,322 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
SlashCommand,
SlashCommandActionReturn,
CommandContext,
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import type { HookRegistryEntry } from '@qwen-code/qwen-code-core';
/**
* Format hook source for display
*/
function formatHookSource(source: string): string {
switch (source) {
case 'project':
return 'Project';
case 'user':
return 'User';
case 'system':
return 'System';
case 'extensions':
return 'Extension';
default:
return source;
}
}
/**
* Format hook status for display
*/
function formatHookStatus(enabled: boolean): string {
return enabled ? '✓ Enabled' : '✗ Disabled';
}
const listCommand: SlashCommand = {
name: 'list',
get description() {
return t('List all configured hooks');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
_args: string,
): Promise<MessageActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const hookSystem = config.getHookSystem();
if (!hookSystem) {
return {
type: 'message',
messageType: 'info',
content: t(
'Hooks are not enabled. Enable hooks in settings to use this feature.',
),
};
}
const registry = hookSystem.getRegistry();
const allHooks = registry.getAllHooks();
if (allHooks.length === 0) {
return {
type: 'message',
messageType: 'info',
content: t(
'No hooks configured. Add hooks in your settings.json file.',
),
};
}
// Group hooks by event
const hooksByEvent = new Map<string, HookRegistryEntry[]>();
for (const hook of allHooks) {
const eventName = hook.eventName;
if (!hooksByEvent.has(eventName)) {
hooksByEvent.set(eventName, []);
}
hooksByEvent.get(eventName)!.push(hook);
}
let output = `**Configured Hooks (${allHooks.length} total)**\n\n`;
for (const [eventName, hooks] of hooksByEvent) {
output += `### ${eventName}\n`;
for (const hook of hooks) {
const name = hook.config.name || hook.config.command || 'unnamed';
const source = formatHookSource(hook.source);
const status = formatHookStatus(hook.enabled);
const matcher = hook.matcher ? ` (matcher: ${hook.matcher})` : '';
output += `- **${name}** [${source}] ${status}${matcher}\n`;
}
output += '\n';
}
return {
type: 'message',
messageType: 'info',
content: output,
};
},
};
const enableCommand: SlashCommand = {
name: 'enable',
get description() {
return t('Enable a disabled hook');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const hookName = args.trim();
if (!hookName) {
return {
type: 'message',
messageType: 'error',
content: t(
'Please specify a hook name. Usage: /hooks enable <hook-name>',
),
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const hookSystem = config.getHookSystem();
if (!hookSystem) {
return {
type: 'message',
messageType: 'error',
content: t('Hooks are not enabled.'),
};
}
const registry = hookSystem.getRegistry();
registry.setHookEnabled(hookName, true);
return {
type: 'message',
messageType: 'info',
content: t('Hook "{{name}}" has been enabled for this session.', {
name: hookName,
}),
};
},
completion: async (context: CommandContext, partialArg: string) => {
const { config } = context.services;
if (!config) return [];
const hookSystem = config.getHookSystem();
if (!hookSystem) return [];
const registry = hookSystem.getRegistry();
const allHooks = registry.getAllHooks();
// Return disabled hooks for enable command (deduplicated by name)
const disabledHookNames = allHooks
.filter((hook) => !hook.enabled)
.map((hook) => hook.config.name || hook.config.command || '')
.filter((name) => name && name.startsWith(partialArg));
return [...new Set(disabledHookNames)];
},
};
const disableCommand: SlashCommand = {
name: 'disable',
get description() {
return t('Disable an active hook');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const hookName = args.trim();
if (!hookName) {
return {
type: 'message',
messageType: 'error',
content: t(
'Please specify a hook name. Usage: /hooks disable <hook-name>',
),
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const hookSystem = config.getHookSystem();
if (!hookSystem) {
return {
type: 'message',
messageType: 'error',
content: t('Hooks are not enabled.'),
};
}
const registry = hookSystem.getRegistry();
registry.setHookEnabled(hookName, false);
return {
type: 'message',
messageType: 'info',
content: t('Hook "{{name}}" has been disabled for this session.', {
name: hookName,
}),
};
},
completion: async (context: CommandContext, partialArg: string) => {
const { config } = context.services;
if (!config) return [];
const hookSystem = config.getHookSystem();
if (!hookSystem) return [];
const registry = hookSystem.getRegistry();
const allHooks = registry.getAllHooks();
// Return enabled hooks for disable command (deduplicated by name)
const enabledHookNames = allHooks
.filter((hook) => hook.enabled)
.map((hook) => hook.config.name || hook.config.command || '')
.filter((name) => name && name.startsWith(partialArg));
return [...new Set(enabledHookNames)];
},
};
export const hooksCommand: SlashCommand = {
name: 'hooks',
get description() {
return t('Manage Qwen Code hooks');
},
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, enableCommand, disableCommand],
action: async (
context: CommandContext,
args: string,
): Promise<SlashCommandActionReturn> => {
// If no subcommand provided, show list
if (!args.trim()) {
const result = await listCommand.action?.(context, '');
return result ?? { type: 'message', messageType: 'info', content: '' };
}
const [subcommand, ...rest] = args.trim().split(/\s+/);
const subArgs = rest.join(' ');
let result: SlashCommandActionReturn | void;
switch (subcommand.toLowerCase()) {
case 'list':
result = await listCommand.action?.(context, subArgs);
break;
case 'enable':
result = await enableCommand.action?.(context, subArgs);
break;
case 'disable':
result = await disableCommand.action?.(context, subArgs);
break;
default:
return {
type: 'message',
messageType: 'error',
content: t(
'Unknown subcommand: {{cmd}}. Available: list, enable, disable',
{
cmd: subcommand,
},
),
};
}
return result ?? { type: 'message', messageType: 'info', content: '' };
},
completion: async (context: CommandContext, partialArg: string) => {
const subcommands = ['list', 'enable', 'disable'];
const parts = partialArg.split(/\s+/);
if (parts.length <= 1) {
// Complete subcommand
return subcommands.filter((cmd) => cmd.startsWith(partialArg));
}
// Complete subcommand arguments
const [subcommand, ...rest] = parts;
const subArgs = rest.join(' ');
switch (subcommand.toLowerCase()) {
case 'enable':
return enableCommand.completion?.(context, subArgs) ?? [];
case 'disable':
return disableCommand.completion?.(context, subArgs) ?? [];
default:
return [];
}
},
};

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>
);
};

View file

@ -1335,6 +1335,40 @@ describe('KeypressContext - Kitty Protocol', () => {
);
});
describe('Printable CSI-u keys', () => {
it('parses kitty CSI-u space as a space key with literal sequence', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[32u`));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'space',
sequence: ' ',
kittyProtocol: true,
}),
);
});
it('parses kitty CSI-u printable letters as literal input', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[100u`)); // 'd'
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'd',
sequence: 'd',
kittyProtocol: true,
}),
);
});
});
describe('Shift+Tab forms', () => {
it.each([
{ sequence: `\x1b[Z`, description: 'legacy reverse Tab' },

View file

@ -332,6 +332,36 @@ export function KeypressProvider({
};
}
// Printable CSI-u keys (including space) should behave like regular
// character input so downstream text inputs receive the literal char.
if (
terminator === 'u' &&
!ctrl &&
keyCode >= 32 &&
keyCode !== 127 &&
keyCode <= 0x10ffff
) {
const char = String.fromCodePoint(keyCode);
const printableName =
char === ' '
? 'space'
: /^[A-Za-z]$/.test(char)
? char.toLowerCase()
: char;
return {
key: {
name: printableName,
ctrl: false,
meta: alt,
shift,
paste: false,
sequence: char,
kittyProtocol: true,
},
length: m[0].length,
};
}
// Ctrl+letters
if (
ctrl &&

View file

@ -1038,6 +1038,15 @@ export const useGeminiStream = (
clearRetryCountdown();
}
break;
case ServerGeminiEventType.HookSystemMessage:
// Display system message from hooks (e.g., Ralph Loop iteration info)
// This is handled as a content event to show in the UI
geminiMessageBuffer = handleContentEvent(
event.value + '\n',
geminiMessageBuffer,
userMessageTimestamp,
);
break;
default: {
// enforces exhaustive switch-case
const unreachable: never = event;

View file

@ -33,6 +33,7 @@ const noColorSemanticColors: SemanticColors = {
secondary: '',
link: '',
accent: '',
code: '',
},
background: {
primary: '',

View file

@ -12,6 +12,7 @@ export interface SemanticColors {
secondary: string;
link: string;
accent: string;
code: string;
};
background: {
primary: string;
@ -45,6 +46,7 @@ export const lightSemanticColors: SemanticColors = {
secondary: lightTheme.Gray,
link: lightTheme.AccentBlue,
accent: lightTheme.AccentPurple,
code: lightTheme.LightBlue,
},
background: {
primary: lightTheme.Background,
@ -77,6 +79,7 @@ export const darkSemanticColors: SemanticColors = {
secondary: darkTheme.Gray,
link: darkTheme.AccentBlue,
accent: darkTheme.AccentPurple,
code: darkTheme.LightBlue,
},
background: {
primary: darkTheme.Background,
@ -109,6 +112,7 @@ export const ansiSemanticColors: SemanticColors = {
secondary: ansiTheme.Gray,
link: ansiTheme.AccentBlue,
accent: ansiTheme.AccentPurple,
code: ansiTheme.LightBlue,
},
background: {
primary: ansiTheme.Background,

View file

@ -40,6 +40,7 @@ export interface CustomTheme {
secondary?: string;
link?: string;
accent?: string;
code?: string;
};
background?: {
primary?: string;
@ -174,6 +175,7 @@ export class Theme {
secondary: this.colors.Gray,
link: this.colors.AccentBlue,
accent: this.colors.AccentPurple,
code: this.colors.LightBlue,
},
background: {
primary: this.colors.Background,
@ -269,7 +271,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
type: 'custom',
Background: customTheme.background?.primary ?? customTheme.Background ?? '',
Foreground: customTheme.text?.primary ?? customTheme.Foreground ?? '',
LightBlue: customTheme.text?.link ?? customTheme.LightBlue ?? '',
LightBlue: customTheme.text?.code ?? customTheme.LightBlue ?? '',
AccentBlue: customTheme.text?.link ?? customTheme.AccentBlue ?? '',
AccentPurple: customTheme.text?.accent ?? customTheme.AccentPurple ?? '',
AccentCyan: customTheme.text?.link ?? customTheme.AccentCyan ?? '',
@ -433,6 +435,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
secondary: customTheme.text?.secondary ?? colors.Gray,
link: customTheme.text?.link ?? colors.AccentBlue,
accent: customTheme.text?.accent ?? colors.AccentPurple,
code: customTheme.text?.code ?? colors.LightBlue,
},
background: {
primary: customTheme.background?.primary ?? colors.Background,