Merge remote-tracking branch 'origin/main' into feat/review-skill-improvements

This commit is contained in:
wenshao 2026-04-07 22:15:13 +08:00
commit 16640e92b9
40 changed files with 904 additions and 2358 deletions

3
.github/CODEOWNERS vendored
View file

@ -1,3 +0,0 @@
* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy @DragonnZhang
# SDK TypeScript package changes require review from Mingholy
packages/sdk-typescript/** @Mingholy

2035
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,10 +12,21 @@ import type {
BaseInfo, BaseInfo,
} from './types.js'; } from './types.js';
const CHANNEL_VERSION = '0.1.0'; // iLink Bot API protocol version we are compatible with.
// Used both in the request body (base_info.channel_version) and in the
// iLink-App-ClientVersion header (encoded as 0x00MMNNPP).
const ILINK_PROTOCOL_VERSION = '2.1.3';
function buildClientVersion(version: string): number {
const parts = version.split('.').map((p) => parseInt(p, 10));
const major = parts[0] ?? 0;
const minor = parts[1] ?? 0;
const patch = parts[2] ?? 0;
return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff);
}
function baseInfo(): BaseInfo { function baseInfo(): BaseInfo {
return { channel_version: CHANNEL_VERSION }; return { channel_version: ILINK_PROTOCOL_VERSION };
} }
function randomUin(): string { function randomUin(): string {
@ -28,6 +39,10 @@ function buildHeaders(token?: string): Record<string, string> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-WECHAT-UIN': randomUin(), 'X-WECHAT-UIN': randomUin(),
'iLink-App-Id': 'bot',
'iLink-App-ClientVersion': String(
buildClientVersion(ILINK_PROTOCOL_VERSION),
),
}; };
if (token) { if (token) {
headers['AuthorizationType'] = 'ilink_bot_token'; headers['AuthorizationType'] = 'ilink_bot_token';

View file

@ -51,6 +51,7 @@ export enum Command {
EXIT = 'exit', EXIT = 'exit',
SHOW_MORE_LINES = 'showMoreLines', SHOW_MORE_LINES = 'showMoreLines',
RETRY_LAST = 'retryLast', RETRY_LAST = 'retryLast',
TOGGLE_VERBOSE_MODE = 'toggleVerboseMode',
// Shell commands // Shell commands
REVERSE_SEARCH = 'reverseSearch', REVERSE_SEARCH = 'reverseSearch',
@ -172,6 +173,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.EXIT]: [{ key: 'd', ctrl: true }], [Command.EXIT]: [{ key: 'd', ctrl: true }],
[Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
[Command.RETRY_LAST]: [{ key: 'y', ctrl: true }], [Command.RETRY_LAST]: [{ key: 'y', ctrl: true }],
[Command.TOGGLE_VERBOSE_MODE]: [{ key: 'o', ctrl: true }],
// Shell commands // Shell commands
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],

View file

@ -582,6 +582,16 @@ const SETTINGS_SCHEMA = {
description: 'The last time the feedback dialog was shown.', description: 'The last time the feedback dialog was shown.',
showInDialog: false, showInDialog: false,
}, },
verboseMode: {
type: 'boolean',
label: 'Verbose Mode',
category: 'UI',
requiresRestart: false,
default: true,
description:
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).',
showInDialog: false,
},
}, },
}, },

View file

@ -1968,4 +1968,9 @@ export default {
'Raw-Modus nicht verfügbar. Bitte in einem interaktiven Terminal ausführen.', 'Raw-Modus nicht verfügbar. Bitte in einem interaktiven Terminal ausführen.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ Pfeiltasten zum Navigieren, Enter zum Auswählen, Strg+C zum Beenden)\n', '(↑ ↓ Pfeiltasten zum Navigieren, Enter zum Auswählen, Strg+C zum Beenden)\n',
verbose: 'ausführlich',
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).':
'Vollständige Tool-Ausgabe und Denkprozess im ausführlichen Modus anzeigen (mit Strg+O umschalten).',
'Press Ctrl+O to show full tool output':
'Strg+O für vollständige Tool-Ausgabe drücken',
}; };

View file

@ -2008,4 +2008,9 @@ export default {
'Raw mode not available. Please run in an interactive terminal.', 'Raw mode not available. Please run in an interactive terminal.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n', '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n',
verbose: 'verbose',
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).':
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).',
'Press Ctrl+O to show full tool output':
'Press Ctrl+O to show full tool output',
}; };

View file

@ -1460,4 +1460,8 @@ export default {
'Rawモードが利用できません。インタラクティブターミナルで実行してください。', 'Rawモードが利用できません。インタラクティブターミナルで実行してください。',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ 矢印キーで移動、Enter で選択、Ctrl+C で終了)\n', '(↑ ↓ 矢印キーで移動、Enter で選択、Ctrl+C で終了)\n',
verbose: '詳細',
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).':
'詳細モードで完全なツール出力と思考を表示しますCtrl+O で切り替え)。',
'Press Ctrl+O to show full tool output': 'Ctrl+O で完全なツール出力を表示',
}; };

View file

@ -1958,4 +1958,9 @@ export default {
'Modo raw não disponível. Execute em um terminal interativo.', 'Modo raw não disponível. Execute em um terminal interativo.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(Use ↑ ↓ para navegar, Enter para selecionar, Ctrl+C para sair)\n', '(Use ↑ ↓ para navegar, Enter para selecionar, Ctrl+C para sair)\n',
verbose: 'detalhado',
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).':
'Mostrar saída completa da ferramenta e raciocínio no modo detalhado (alternar com Ctrl+O).',
'Press Ctrl+O to show full tool output':
'Pressione Ctrl+O para exibir a saída completa da ferramenta',
}; };

View file

@ -1965,4 +1965,9 @@ export default {
'Raw-режим недоступен. Пожалуйста, запустите в интерактивном терминале.', 'Raw-режим недоступен. Пожалуйста, запустите в интерактивном терминале.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ стрелки для навигации, Enter для выбора, Ctrl+C для выхода)\n', '(↑ ↓ стрелки для навигации, Enter для выбора, Ctrl+C для выхода)\n',
verbose: 'подробный',
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).':
'Показывать полный вывод инструментов и процесс рассуждений в подробном режиме (переключить с помощью Ctrl+O).',
'Press Ctrl+O to show full tool output':
'Нажмите Ctrl+O для показа полного вывода инструментов',
}; };

View file

@ -1813,4 +1813,8 @@ export default {
'原始模式不可用。请在交互式终端中运行。', '原始模式不可用。请在交互式终端中运行。',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(使用 ↑ ↓ 箭头导航Enter 选择Ctrl+C 退出)\n', '(使用 ↑ ↓ 箭头导航Enter 选择Ctrl+C 退出)\n',
verbose: '详细',
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).':
'详细模式下显示完整工具输出和思考过程Ctrl+O 切换)。',
'Press Ctrl+O to show full tool output': '按 Ctrl+O 查看详细工具调用结果',
}; };

View file

@ -71,6 +71,7 @@ import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
import { useResumeCommand } from './hooks/useResumeCommand.js'; import { useResumeCommand } from './hooks/useResumeCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useVimMode } from './contexts/VimModeContext.js'; import { useVimMode } from './contexts/VimModeContext.js';
import { VerboseModeProvider } from './contexts/VerboseModeContext.js';
import { useTerminalSize } from './hooks/useTerminalSize.js'; import { useTerminalSize } from './hooks/useTerminalSize.js';
import { calculatePromptWidths } from './components/InputPrompt.js'; import { calculatePromptWidths } from './components/InputPrompt.js';
import { useStdin, useStdout } from 'ink'; import { useStdin, useStdout } from 'ink';
@ -963,6 +964,11 @@ export const AppContainer = (props: AppContainerProps) => {
handleWelcomeBackClose, handleWelcomeBackClose,
} = useWelcomeBack(config, handleFinalSubmit, buffer, settings.merged); } = useWelcomeBack(config, handleFinalSubmit, buffer, settings.merged);
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
cancelHandlerRef.current = useCallback(() => { cancelHandlerRef.current = useCallback(() => {
const pendingHistoryItems = [ const pendingHistoryItems = [
...pendingSlashCommandHistoryItems, ...pendingSlashCommandHistoryItems,
@ -1259,6 +1265,13 @@ export const AppContainer = (props: AppContainerProps) => {
const [showToolDescriptions, setShowToolDescriptions] = const [showToolDescriptions, setShowToolDescriptions] =
useState<boolean>(false); useState<boolean>(false);
const [verboseMode, setVerboseMode] = useState<boolean>(
settings.merged.ui?.verboseMode ?? true,
);
const [frozenSnapshot, setFrozenSnapshot] = useState<
HistoryItemWithoutId[] | null
>(null);
const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null); const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
@ -1273,6 +1286,18 @@ export const AppContainer = (props: AppContainerProps) => {
const [showEscapePrompt, setShowEscapePrompt] = useState(false); const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
useEffect(() => {
// Clear frozen snapshot when streaming ends OR when entering confirmation
// state. During WaitingForConfirmation, the user needs to see the latest
// pending items (including the confirmation message) rather than a stale snapshot.
if (
streamingState === StreamingState.Idle ||
streamingState === StreamingState.WaitingForConfirmation
) {
setFrozenSnapshot(null);
}
}, [streamingState]);
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
useFolderTrust(settings, setIsTrustedFolder); useFolderTrust(settings, setIsTrustedFolder);
const { const {
@ -1659,6 +1684,18 @@ export const AppContainer = (props: AppContainerProps) => {
if (activePtyId || embeddedShellFocused) { if (activePtyId || embeddedShellFocused) {
setEmbeddedShellFocused((prev) => !prev); setEmbeddedShellFocused((prev) => !prev);
} }
} else if (keyMatchers[Command.TOGGLE_VERBOSE_MODE](key)) {
const newValue = !verboseMode;
setVerboseMode(newValue);
void settings.setValue(SettingScope.User, 'ui.verboseMode', newValue);
refreshStatic();
// Only freeze during the actual responding phase. WaitingForConfirmation
// must keep focus so the user can approve/cancel tool confirmation UI.
if (streamingState === StreamingState.Responding) {
setFrozenSnapshot([...pendingHistoryItems]);
} else {
setFrozenSnapshot(null);
}
} }
}, },
[ [
@ -1687,8 +1724,16 @@ export const AppContainer = (props: AppContainerProps) => {
btwItem, btwItem,
setBtwItem, setBtwItem,
cancelBtw, cancelBtw,
settings.merged.general?.debugKeystrokeLogging, // `settings` is a stable LoadedSettings instance (not recreated on render).
// ESLint requires it here because the callback calls settings.setValue().
// debugKeystrokeLogging is read at call time, so no stale closure risk.
settings,
isAuthenticating, isAuthenticating,
verboseMode,
setVerboseMode,
setFrozenSnapshot,
pendingHistoryItems,
refreshStatic,
], ],
); );
@ -1777,11 +1822,6 @@ export const AppContainer = (props: AppContainerProps) => {
sessionStats, sessionStats,
}); });
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
const uiState: UIState = useMemo( const uiState: UIState = useMemo(
() => ({ () => ({
history: historyManager.history, history: historyManager.history,
@ -2117,6 +2157,11 @@ export const AppContainer = (props: AppContainerProps) => {
], ],
); );
const verboseModeValue = useMemo(
() => ({ verboseMode, frozenSnapshot }),
[verboseMode, frozenSnapshot],
);
return ( return (
<UIStateContext.Provider value={uiState}> <UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}> <UIActionsContext.Provider value={uiActions}>
@ -2127,9 +2172,11 @@ export const AppContainer = (props: AppContainerProps) => {
startupWarnings: props.startupWarnings || [], startupWarnings: props.startupWarnings || [],
}} }}
> >
<ShellFocusContext.Provider value={isFocused}> <VerboseModeProvider value={verboseModeValue}>
<App /> <ShellFocusContext.Provider value={isFocused}>
</ShellFocusContext.Provider> <App />
</ShellFocusContext.Provider>
</VerboseModeProvider>
</AppContext.Provider> </AppContext.Provider>
</ConfigContext.Provider> </ConfigContext.Provider>
</UIActionsContext.Provider> </UIActionsContext.Provider>

View file

@ -16,6 +16,7 @@ import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js'; import { useConfig } from '../contexts/ConfigContext.js';
import { useVimMode } from '../contexts/VimModeContext.js'; import { useVimMode } from '../contexts/VimModeContext.js';
import { useVerboseMode } from '../contexts/VerboseModeContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core'; import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
@ -23,6 +24,7 @@ export const Footer: React.FC = () => {
const uiState = useUIState(); const uiState = useUIState();
const config = useConfig(); const config = useConfig();
const { vimEnabled, vimMode } = useVimMode(); const { vimEnabled, vimMode } = useVimMode();
const { verboseMode } = useVerboseMode();
const { promptTokenCount, showAutoAcceptIndicator } = { const { promptTokenCount, showAutoAcceptIndicator } = {
promptTokenCount: uiState.sessionStats.lastPromptTokenCount, 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 ( return (
<Box <Box
justifyContent="space-between" justifyContent="space-between"

View file

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

View file

@ -2418,36 +2418,6 @@ describe('InputPrompt', () => {
unmount(); unmount();
}); });
it('should delete entire placeholder on backspace', async () => {
const placeholderText = '[Pasted Content 1001 chars]';
mockBuffer.text = placeholderText;
mockBuffer.lines = [placeholderText];
mockBuffer.cursor = [0, placeholderText.length];
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
// First set up a placeholder via paste
const largeContent = 'x'.repeat(1001);
stdin.write(`\x1b[200~${largeContent}\x1b[201~`);
await wait();
// Press backspace to delete the placeholder
stdin.write('\x7f'); // backspace character
await wait();
// Verify replaceRangeByOffset was called to delete entire placeholder
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith(
0,
placeholderText.length,
'',
);
unmount();
});
it('should reuse placeholder ID after deletion', async () => { it('should reuse placeholder ID after deletion', async () => {
// Set up mocks that actually update buffer state // Set up mocks that actually update buffer state
vi.mocked(mockBuffer.insert).mockImplementation((text: string) => { vi.mocked(mockBuffer.insert).mockImplementation((text: string) => {

View file

@ -13,6 +13,7 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useAppContext } from '../contexts/AppContext.js'; import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js'; import { AppHeader } from './AppHeader.js';
import { DebugModeNotification } from './DebugModeNotification.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 // 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. // 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 = () => { export const MainContent = () => {
const { version } = useAppContext(); const { version } = useAppContext();
const uiState = useUIState(); const uiState = useUIState();
const { frozenSnapshot } = useVerboseMode();
const { const {
pendingHistoryItems, pendingHistoryItems,
terminalWidth, terminalWidth,
@ -57,21 +59,26 @@ export const MainContent = () => {
</Static> </Static>
<OverflowProvider> <OverflowProvider>
<Box flexDirection="column"> <Box flexDirection="column">
{pendingHistoryItems.map((item, i) => ( {(frozenSnapshot ?? pendingHistoryItems).map((item, i) => {
<HistoryItemDisplay const isFrozen = frozenSnapshot !== null;
key={i} return (
availableTerminalHeight={ <HistoryItemDisplay
uiState.constrainHeight ? availableTerminalHeight : undefined key={i}
} availableTerminalHeight={
terminalWidth={terminalWidth} uiState.constrainHeight ? availableTerminalHeight : undefined
mainAreaWidth={mainAreaWidth} }
item={{ ...item, id: 0 }} terminalWidth={terminalWidth}
isPending={true} mainAreaWidth={mainAreaWidth}
isFocused={!uiState.isEditorDialogOpen} item={{ ...item, id: 0 }}
activeShellPtyId={uiState.activePtyId} isPending={true}
embeddedShellFocused={uiState.embeddedShellFocused} isFocused={isFrozen ? false : !uiState.isEditorDialogOpen}
/> activeShellPtyId={isFrozen ? undefined : uiState.activePtyId}
))} embeddedShellFocused={
isFrozen ? false : uiState.embeddedShellFocused
}
/>
);
})}
<ShowMoreLines constrainHeight={uiState.constrainHeight} /> <ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box> </Box>
</OverflowProvider> </OverflowProvider>

View file

@ -1,5 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // 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

@ -173,34 +173,6 @@ describe('<AskUserQuestionDialog />', () => {
); );
unmount(); unmount();
}); });
it('navigates with number keys', async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails();
const { stdin, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
await wait();
// Press '2' to select Blue
stdin.write('2');
await wait();
// Press Enter
stdin.write('\r');
await wait();
expect(onConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
{ answers: { 0: 'Blue' } },
);
unmount();
});
it('cancels with Escape', async () => { it('cancels with Escape', async () => {
const onConfirm = vi.fn(); const onConfirm = vi.fn();
const details = createConfirmationDetails(); const details = createConfirmationDetails();

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 { ToolCallStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js'; import { ToolMessage } from './ToolMessage.js';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { CompactToolGroupDisplay } from './CompactToolGroupDisplay.js';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../semantic-colors.js';
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js'; import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js'; import { useConfig } from '../../contexts/ConfigContext.js';
import { useVerboseMode } from '../../contexts/VerboseModeContext.js';
interface ToolGroupMessageProps { interface ToolGroupMessageProps {
groupId: number; groupId: number;
@ -24,6 +26,7 @@ interface ToolGroupMessageProps {
activeShellPtyId?: number | null; activeShellPtyId?: number | null;
embeddedShellFocused?: boolean; embeddedShellFocused?: boolean;
onShellInputSubmit?: (input: string) => void; onShellInputSubmit?: (input: string) => void;
isUserInitiated?: boolean;
} }
// Main component renders the border and maps the tools using ToolMessage // Main component renders the border and maps the tools using ToolMessage
@ -34,7 +37,15 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
isFocused = true, isFocused = true,
activeShellPtyId, activeShellPtyId,
embeddedShellFocused, 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 = const isEmbeddedShellFocused =
embeddedShellFocused && embeddedShellFocused &&
toolCalls.some( toolCalls.some(
@ -42,11 +53,36 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
t.ptyId === activeShellPtyId && t.status === ToolCallStatus.Executing, 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( const hasPending = !toolCalls.every(
(t) => t.status === ToolCallStatus.Success, (t) => t.status === ToolCallStatus.Success,
); );
const config = useConfig();
const isShellCommand = toolCalls.some( const isShellCommand = toolCalls.some(
(t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME, (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) // account for border (2 chars) and padding (2 chars)
const innerWidth = contentWidth - 4; 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; let countToolCallsWithResults = 0;
for (const tool of toolCalls) { for (const tool of toolCalls) {
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') { if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
@ -121,6 +150,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
activeShellPtyId={activeShellPtyId} activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused} embeddedShellFocused={embeddedShellFocused}
config={config} config={config}
forceShowResult={
isUserInitiated ||
tool.status === ToolCallStatus.Confirming ||
tool.status === ToolCallStatus.Error
}
/> />
</Box> </Box>
{tool.status === ToolCallStatus.Confirming && {tool.status === ToolCallStatus.Confirming &&

View file

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

View file

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

View file

@ -0,0 +1,23 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
import type { HistoryItemWithoutId } from '../types.js';
interface VerboseModeContextType {
verboseMode: boolean;
frozenSnapshot: HistoryItemWithoutId[] | null;
}
const VerboseModeContext = createContext<VerboseModeContextType>({
verboseMode: true,
frozenSnapshot: null,
});
export const useVerboseMode = (): VerboseModeContextType =>
useContext(VerboseModeContext);
export const VerboseModeProvider = VerboseModeContext.Provider;

View file

@ -143,6 +143,7 @@ describe('useShellCommandProcessor', () => {
status: ToolCallStatus.Executing, status: ToolCallStatus.Executing,
}), }),
], ],
isUserInitiated: true,
}); });
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp'); const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
const wrappedCommand = `{ ls -l; }; __code=$?; pwd > "${tmpFile}"; exit $__code`; const wrappedCommand = `{ ls -l; }; __code=$?; pwd > "${tmpFile}"; exit $__code`;

View file

@ -131,6 +131,7 @@ export const useShellCommandProcessor = (
setPendingHistoryItem({ setPendingHistoryItem({
type: 'tool_group', type: 'tool_group',
tools: [initialToolDisplay], tools: [initialToolDisplay],
isUserInitiated: true,
}); });
let executionPid: number | undefined; let executionPid: number | undefined;
@ -304,6 +305,7 @@ export const useShellCommandProcessor = (
{ {
type: 'tool_group', type: 'tool_group',
tools: [finalToolDisplay], tools: [finalToolDisplay],
isUserInitiated: true,
} as HistoryItemWithoutId, } as HistoryItemWithoutId,
userMessageTimestamp, userMessageTimestamp,
); );

View file

@ -60,6 +60,7 @@ describe('keyMatchers', () => {
[Command.EXIT]: (key: Key) => key.ctrl && key.name === 'd', [Command.EXIT]: (key: Key) => key.ctrl && key.name === 'd',
[Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's', [Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's',
[Command.RETRY_LAST]: (key: Key) => key.ctrl && key.name === 'y', [Command.RETRY_LAST]: (key: Key) => key.ctrl && key.name === 'y',
[Command.TOGGLE_VERBOSE_MODE]: (key: Key) => key.ctrl && key.name === 'o',
[Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r', [Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r',
[Command.SUBMIT_REVERSE_SEARCH]: (key: Key) => [Command.SUBMIT_REVERSE_SEARCH]: (key: Key) =>
key.name === 'return' && !key.ctrl, key.name === 'return' && !key.ctrl,
@ -258,6 +259,11 @@ describe('keyMatchers', () => {
positive: [createKey('y', { ctrl: true })], positive: [createKey('y', { ctrl: true })],
negative: [createKey('y'), createKey('r', { ctrl: true })], negative: [createKey('y'), createKey('r', { ctrl: true })],
}, },
{
command: Command.TOGGLE_VERBOSE_MODE,
positive: [createKey('o', { ctrl: true })],
negative: [createKey('o'), createKey('p', { ctrl: true })],
},
// Shell commands // Shell commands
{ {

View file

@ -185,6 +185,7 @@ export type HistoryItemQuit = HistoryItemBase & {
export type HistoryItemToolGroup = HistoryItemBase & { export type HistoryItemToolGroup = HistoryItemBase & {
type: 'tool_group'; type: 'tool_group';
tools: IndividualToolCallDisplay[]; tools: IndividualToolCallDisplay[];
isUserInitiated?: boolean;
}; };
export type HistoryItemUserShell = HistoryItemBase & { export type HistoryItemUserShell = HistoryItemBase & {

View file

@ -210,6 +210,132 @@ describe('SchemaValidator', () => {
}); });
}); });
describe('stringified JSON value coercion', () => {
it('should coerce stringified array for anyOf [array, null]', () => {
const schema = {
type: 'object',
properties: {
urls: {
anyOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'null' },
],
default: null,
},
},
};
const params = { urls: '["https://example.com"]' };
expect(SchemaValidator.validate(schema, params)).toBeNull();
expect(params.urls).toEqual(['https://example.com']);
});
it('should coerce stringified object for anyOf [object, null]', () => {
const schema = {
type: 'object',
properties: {
config: {
anyOf: [
{
type: 'object',
properties: { key: { type: 'string' } },
},
{ type: 'null' },
],
},
},
};
const params = { config: '{"key":"value"}' };
expect(SchemaValidator.validate(schema, params)).toBeNull();
expect(params.config).toEqual({ key: 'value' });
});
it('should coerce stringified array for oneOf [array, null]', () => {
const schema = {
type: 'object',
properties: {
items: {
oneOf: [
{ type: 'array', items: { type: 'integer' } },
{ type: 'null' },
],
},
},
};
const params = { items: '[1, 2, 3]' };
expect(SchemaValidator.validate(schema, params)).toBeNull();
expect(params.items).toEqual([1, 2, 3]);
});
it('should not coerce when schema accepts string type', () => {
const schema = {
type: 'object',
properties: {
data: {
anyOf: [
{ type: 'string' },
{ type: 'array', items: { type: 'string' } },
],
},
},
};
const params = { data: '["hello"]' };
expect(SchemaValidator.validate(schema, params)).toBeNull();
// Value should remain a string since string is accepted
expect(params.data).toBe('["hello"]');
});
it('should not coerce invalid JSON strings', () => {
const schema = {
type: 'object',
properties: {
urls: {
anyOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'null' },
],
},
},
};
const params = { urls: '[not valid json' };
expect(SchemaValidator.validate(schema, params)).not.toBeNull();
});
it('should not coerce strings that do not look like JSON', () => {
const schema = {
type: 'object',
properties: {
urls: {
anyOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'null' },
],
},
},
required: ['urls'],
};
const params = { urls: 'hello world' };
expect(SchemaValidator.validate(schema, params)).not.toBeNull();
});
it('should handle stringified array with plain type (no anyOf)', () => {
// Should NOT coerce when there is no anyOf/oneOf — the schema just
// says type: array, and a string value is simply invalid.
const schema = {
type: 'object',
properties: {
urls: { type: 'array', items: { type: 'string' } },
},
required: ['urls'],
};
const params = { urls: '["https://example.com"]' };
// No anyOf/oneOf, so fixStringifiedJsonValues won't have types to check
// against — but getAcceptedTypes reads plain 'type' too, so it should
// still coerce since 'string' is not in the accepted types.
expect(SchemaValidator.validate(schema, params)).toBeNull();
expect(params.urls).toEqual(['https://example.com']);
});
});
describe('JSON Schema version support', () => { describe('JSON Schema version support', () => {
it('should support JSON Schema draft-2020-12', () => { it('should support JSON Schema draft-2020-12', () => {
const schema = { const schema = {
@ -280,6 +406,29 @@ describe('SchemaValidator', () => {
expect(SchemaValidator.validate(schema, params)).toBeNull(); expect(SchemaValidator.validate(schema, params)).toBeNull();
}); });
it('should handle anyOf union types with draft-2020-12', () => {
const schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {
urls: {
anyOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'null' },
],
default: null,
},
},
};
expect(
SchemaValidator.validate(schema, {
urls: ['https://example.com'],
}),
).toBeNull();
expect(SchemaValidator.validate(schema, { urls: null })).toBeNull();
expect(SchemaValidator.validate(schema, {})).toBeNull();
});
it('should gracefully handle unsupported schema versions', () => { it('should gracefully handle unsupported schema versions', () => {
// draft-2019-09 is not supported by Ajv by default // draft-2019-09 is not supported by Ajv by default
const schema = { const schema = {

View file

@ -102,6 +102,13 @@ export class SchemaValidator {
if (!valid && validate.errors) { if (!valid && validate.errors) {
// Coerce string boolean values ("true"/"false") to actual booleans // Coerce string boolean values ("true"/"false") to actual booleans
fixBooleanValues(data as Record<string, unknown>); fixBooleanValues(data as Record<string, unknown>);
// Coerce stringified JSON values (arrays/objects) back to their proper types.
// Some LLMs serialize complex values as strings when the schema uses
// anyOf/oneOf (e.g., '["url"]' instead of ["url"] for anyOf: [array, null]).
fixStringifiedJsonValues(
data as Record<string, unknown>,
anySchema as Record<string, unknown>,
);
valid = validate(data); valid = validate(data);
if (!valid && validate.errors) { if (!valid && validate.errors) {
@ -121,6 +128,92 @@ export class SchemaValidator {
* - "true", "True", "TRUE" -> true * - "true", "True", "TRUE" -> true
* - "false", "False", "FALSE" -> false * - "false", "False", "FALSE" -> false
*/ */
/**
* Returns the set of JSON Schema types that a property accepts,
* considering `type`, `anyOf`, and `oneOf` keywords.
*/
function getAcceptedTypes(
propSchema: Record<string, unknown>,
): Set<string> | null {
const types = new Set<string>();
if (typeof propSchema['type'] === 'string') {
types.add(propSchema['type'] as string);
} else if (Array.isArray(propSchema['type'])) {
for (const t of propSchema['type'] as string[]) {
types.add(t);
}
}
for (const keyword of ['anyOf', 'oneOf']) {
const variants = propSchema[keyword];
if (Array.isArray(variants)) {
for (const variant of variants as Array<Record<string, unknown>>) {
if (typeof variant['type'] === 'string') {
types.add(variant['type'] as string);
} else if (Array.isArray(variant['type'])) {
for (const t of variant['type'] as string[]) {
types.add(t);
}
}
}
}
}
return types.size > 0 ? types : null;
}
/**
* Coerces stringified JSON values back to their proper types.
* Some LLMs serialize arrays/objects as JSON strings when the schema uses
* anyOf/oneOf with mixed types (e.g., `list[str] | None` in Python becomes
* `anyOf: [{type: "array"}, {type: "null"}]`). The model may return
* '["url"]' (a string) instead of ["url"] (an actual array).
*
* This function parses such strings back to their intended type when:
* 1. The value is a string starting with `[` or `{`
* 2. The schema accepts array or object but not string
* 3. The parsed result matches one of the accepted types
*/
function fixStringifiedJsonValues(
data: Record<string, unknown>,
schema: Record<string, unknown>,
) {
const properties = schema['properties'] as
| Record<string, Record<string, unknown>>
| undefined;
if (!properties) return;
for (const key of Object.keys(data)) {
const value = data[key];
const propSchema = properties[key];
if (!propSchema || typeof value !== 'string') continue;
const trimmed = value.trim();
if (
(trimmed.startsWith('[') && trimmed.endsWith(']')) ||
(trimmed.startsWith('{') && trimmed.endsWith('}'))
) {
const accepted = getAcceptedTypes(propSchema);
if (!accepted) continue;
// Only coerce if the schema does NOT accept string — otherwise the
// string value may be intentional.
if (accepted.has('string')) continue;
if (!accepted.has('array') && !accepted.has('object')) continue;
try {
const parsed = JSON.parse(trimmed);
const parsedType = Array.isArray(parsed) ? 'array' : typeof parsed;
if (accepted.has(parsedType)) {
data[key] = parsed;
}
} catch {
// Not valid JSON — leave the value unchanged
}
}
}
}
function fixBooleanValues(data: Record<string, unknown>) { function fixBooleanValues(data: Record<string, unknown>) {
for (const key of Object.keys(data)) { for (const key of Object.keys(data)) {
if (!(key in data)) continue; if (!(key in data)) continue;

View file

@ -207,7 +207,6 @@
"@types/vscode": "^1.85.0", "@types/vscode": "^1.85.0",
"@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1", "@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.6.0",
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
"esbuild": "^0.25.3", "esbuild": "^0.25.3",
"eslint": "^9.25.1", "eslint": "^9.25.1",

View file

@ -214,6 +214,11 @@
"description": "The last time the feedback dialog was shown.", "description": "The last time the feedback dialog was shown.",
"type": "number", "type": "number",
"default": 0 "default": 0
},
"verboseMode": {
"description": "Show full tool output and thinking in verbose mode (toggle with Ctrl+O).",
"type": "boolean",
"default": true
} }
} }
}, },

View file

@ -12,11 +12,6 @@
"import": "./dist/index.js", "import": "./dist/index.js",
"require": "./dist/index.cjs" "require": "./dist/index.cjs"
}, },
"./followup": {
"types": "./dist/followup.d.ts",
"import": "./dist/followup.js",
"require": "./dist/followup.cjs"
},
"./icons": { "./icons": {
"types": "./dist/components/icons/index.d.ts", "types": "./dist/components/icons/index.d.ts",
"import": "./dist/components/icons/index.js", "import": "./dist/components/icons/index.js",
@ -37,7 +32,7 @@
}, },
"scripts": { "scripts": {
"dev": "vite build --watch", "dev": "vite build --watch",
"build": "vite build && vite build --config vite.config.followup.ts", "build": "vite build",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx", "lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix", "lint:fix": "eslint src --ext .ts,.tsx --fix",
@ -45,15 +40,9 @@
"build-storybook": "storybook build" "build-storybook": "storybook build"
}, },
"peerDependencies": { "peerDependencies": {
"@qwen-code/qwen-code-core": ">=0.13.1",
"react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0" "react-dom": "^18.0.0 || ^19.0.0"
}, },
"peerDependenciesMeta": {
"@qwen-code/qwen-code-core": {
"optional": true
}
},
"dependencies": { "dependencies": {
"markdown-it": "^14.1.0" "markdown-it": "^14.1.0"
}, },

View file

@ -22,18 +22,7 @@ import { CompletionMenu } from './CompletionMenu.js';
import { ContextIndicator } from './ContextIndicator.js'; import { ContextIndicator } from './ContextIndicator.js';
import type { CompletionItem } from '../../types/completion.js'; import type { CompletionItem } from '../../types/completion.js';
import type { ContextUsage } from './ContextIndicator.js'; import type { ContextUsage } from './ContextIndicator.js';
/** import type { FollowupState } from '../../types/followup.js';
* Minimal follow-up state shape used by InputForm.
* Defined locally to avoid pulling @qwen-code/qwen-code-core into the
* root entry's type declarations. The full FollowupState lives in
* '@qwen-code/webui/followup'.
*/
interface InputFormFollowupState {
/** Current suggestion text */
suggestion: string | null;
/** Whether to show suggestion */
isVisible: boolean;
}
/** /**
* Edit mode display information * Edit mode display information
@ -141,7 +130,7 @@ export interface InputFormProps {
/** Whether the current draft is eligible to submit */ /** Whether the current draft is eligible to submit */
canSubmit?: boolean; canSubmit?: boolean;
/** Prompt suggestion state */ /** Prompt suggestion state */
followupState?: InputFormFollowupState; followupState?: FollowupState;
/** Callback to accept prompt suggestion */ /** Callback to accept prompt suggestion */
onAcceptFollowup?: (method?: 'tab' | 'enter' | 'right') => void; onAcceptFollowup?: (method?: 'tab' | 'enter' | 'right') => void;
/** Callback to dismiss prompt suggestion */ /** Callback to dismiss prompt suggestion */

View file

@ -1,19 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Prompt Suggestion Subpath Entry
*
* Separated from the root entry to avoid forcing all @qwen-code/webui
* consumers to install @qwen-code/qwen-code-core as a dependency.
*
* Usage: import { useFollowupSuggestions } from '@qwen-code/webui/followup';
*/
export { useFollowupSuggestions } from './hooks/useFollowupSuggestions';
export type {
FollowupState,
UseFollowupSuggestionsOptions,
UseFollowupSuggestionsReturn,
} from './hooks/useFollowupSuggestions';

View file

@ -2,34 +2,27 @@
* @license * @license
* Copyright 2025 Qwen Team * Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*
* Prompt Suggestion Hook
*
* Thin React wrapper around the framework-agnostic controller from core.
*
* Note: For browser environments, the parent component should handle
* suggestion generation and pass the results to this hook.
*/ */
import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { import type { FollowupState } from '../types/followup.js';
INITIAL_FOLLOWUP_STATE, import { INITIAL_FOLLOWUP_STATE } from '../types/followup.js';
createFollowupController,
} from '@qwen-code/qwen-code-core';
import type { FollowupState } from '@qwen-code/qwen-code-core';
// Re-export types from core for convenience export type { FollowupState } from '../types/followup.js';
export type { FollowupState } from '@qwen-code/qwen-code-core';
/** // ---------------------------------------------------------------------------
* Options for the hook // Controller (framework-agnostic)
*/ // ---------------------------------------------------------------------------
export interface UseFollowupSuggestionsOptions {
/** Whether the feature is enabled */ /** Delay before showing suggestion after response completes */
const SUGGESTION_DELAY_MS = 300;
/** Debounce lock duration to prevent rapid-fire accepts */
const ACCEPT_DEBOUNCE_MS = 100;
interface FollowupControllerOptions {
enabled?: boolean; enabled?: boolean;
/** Callback when suggestion is accepted */ onStateChange: (state: FollowupState) => void;
onAccept?: (suggestion: string) => void; getOnAccept?: () => ((text: string) => void) | undefined;
/** Callback when a suggestion outcome is determined */
onOutcome?: (params: { onOutcome?: (params: {
outcome: 'accepted' | 'ignored'; outcome: 'accepted' | 'ignored';
accept_method?: 'tab' | 'enter' | 'right'; accept_method?: 'tab' | 'enter' | 'right';
@ -38,31 +31,175 @@ export interface UseFollowupSuggestionsOptions {
}) => void; }) => void;
} }
/** interface FollowupControllerActions {
* Result returned by the hook setSuggestion: (text: string | null) => void;
*/ accept: (method?: 'tab' | 'enter' | 'right') => void;
export interface UseFollowupSuggestionsReturn { dismiss: () => void;
/** Current state */ clear: () => void;
state: FollowupState; cleanup: () => void;
/** Get current placeholder text */ }
getPlaceholder: (defaultPlaceholder: string) => string;
/** Set suggestion text (called by parent component) */ function createFollowupController(
options: FollowupControllerOptions,
): FollowupControllerActions {
const { enabled = true, onStateChange, getOnAccept, onOutcome } = options;
let currentState: FollowupState = INITIAL_FOLLOWUP_STATE;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let accepting = false;
let acceptTimeoutId: ReturnType<typeof setTimeout> | null = null;
function applyState(next: FollowupState): void {
currentState = next;
onStateChange(next);
}
function clearTimers(): void {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (acceptTimeoutId) {
clearTimeout(acceptTimeoutId);
acceptTimeoutId = null;
}
}
const setSuggestion = (text: string | null): void => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (!text) {
applyState(INITIAL_FOLLOWUP_STATE);
return;
}
if (!enabled) {
return;
}
timeoutId = setTimeout(() => {
applyState({ suggestion: text, isVisible: true, shownAt: Date.now() });
}, SUGGESTION_DELAY_MS);
};
const accept = (method?: 'tab' | 'enter' | 'right'): void => {
if (accepting) {
return;
}
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
accepting = true;
const text = currentState.suggestion;
const { shownAt } = currentState;
if (!text) {
accepting = false;
return;
}
try {
onOutcome?.({
outcome: 'accepted',
accept_method: method,
time_ms: shownAt > 0 ? Date.now() - shownAt : 0,
suggestion_length: text.length,
});
} catch (e: unknown) {
console.error('[followup] onOutcome callback threw:', e);
}
applyState(INITIAL_FOLLOWUP_STATE);
queueMicrotask(() => {
try {
getOnAccept?.()?.(text);
} catch (error: unknown) {
console.error('[followup] onAccept callback threw:', error);
} finally {
if (acceptTimeoutId) {
clearTimeout(acceptTimeoutId);
}
acceptTimeoutId = setTimeout(() => {
accepting = false;
}, ACCEPT_DEBOUNCE_MS);
}
});
};
const dismiss = (): void => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (!currentState.isVisible && !currentState.suggestion) {
return;
}
if (currentState.isVisible && currentState.suggestion) {
try {
onOutcome?.({
outcome: 'ignored',
time_ms:
currentState.shownAt > 0 ? Date.now() - currentState.shownAt : 0,
suggestion_length: currentState.suggestion.length,
});
} catch (e: unknown) {
console.error('[followup] onOutcome callback threw:', e);
}
}
applyState(INITIAL_FOLLOWUP_STATE);
};
const clear = (): void => {
clearTimers();
accepting = false;
applyState(INITIAL_FOLLOWUP_STATE);
};
const cleanup = (): void => {
clearTimers();
accepting = false;
};
return { setSuggestion, accept, dismiss, clear, cleanup };
}
// ---------------------------------------------------------------------------
// React hook
// ---------------------------------------------------------------------------
export interface UseFollowupSuggestionsOptions {
enabled?: boolean;
onAccept?: (suggestion: string) => void;
onOutcome?: (params: {
outcome: 'accepted' | 'ignored';
accept_method?: 'tab' | 'enter' | 'right';
time_ms: number;
suggestion_length: number;
}) => void;
}
export interface UseFollowupSuggestionsReturn {
state: FollowupState;
getPlaceholder: (defaultPlaceholder: string) => string;
setSuggestion: (text: string | null) => void; setSuggestion: (text: string | null) => void;
/** Accept the current suggestion */
accept: (method?: 'tab' | 'enter' | 'right') => void; accept: (method?: 'tab' | 'enter' | 'right') => void;
/** Dismiss the current suggestion */
dismiss: () => void; dismiss: () => void;
/** Clear all state */
clear: () => void; clear: () => void;
} }
/**
* Hook for managing prompt suggestions in the Web UI.
*
* Delegates all timer/debounce/state logic to the shared
* `createFollowupController` from core. Adds a `getPlaceholder`
* helper specific to the WebUI input form.
*/
export function useFollowupSuggestions( export function useFollowupSuggestions(
options: UseFollowupSuggestionsOptions = {}, options: UseFollowupSuggestionsOptions = {},
): UseFollowupSuggestionsReturn { ): UseFollowupSuggestionsReturn {
@ -70,13 +207,11 @@ export function useFollowupSuggestions(
const [state, setState] = useState<FollowupState>(INITIAL_FOLLOWUP_STATE); const [state, setState] = useState<FollowupState>(INITIAL_FOLLOWUP_STATE);
// Keep mutable refs so the controller always sees the latest callbacks
const onAcceptRef = useRef(onAccept); const onAcceptRef = useRef(onAccept);
onAcceptRef.current = onAccept; onAcceptRef.current = onAccept;
const onOutcomeRef = useRef(onOutcome); const onOutcomeRef = useRef(onOutcome);
onOutcomeRef.current = onOutcome; onOutcomeRef.current = onOutcome;
// Create the controller once — it is stable across renders
const controller = useMemo( const controller = useMemo(
() => () =>
createFollowupController({ createFollowupController({
@ -88,7 +223,6 @@ export function useFollowupSuggestions(
[enabled], [enabled],
); );
// Clear state when disabled; clean up timers on unmount
useEffect(() => { useEffect(() => {
if (!enabled) { if (!enabled) {
controller.clear(); controller.clear();
@ -96,7 +230,6 @@ export function useFollowupSuggestions(
return () => controller.cleanup(); return () => controller.cleanup();
}, [controller, enabled]); }, [controller, enabled]);
// WebUI-specific helper: resolves placeholder text
const getPlaceholder = useCallback( const getPlaceholder = useCallback(
(defaultPlaceholder: string) => { (defaultPlaceholder: string) => {
if (state.isVisible && state.suggestion) { if (state.isVisible && state.suggestion) {

View file

@ -231,8 +231,12 @@ export { StopIcon } from './components/icons/StopIcon';
// Hooks // Hooks
export { useTheme } from './hooks/useTheme'; export { useTheme } from './hooks/useTheme';
export { useLocalStorage } from './hooks/useLocalStorage'; export { useLocalStorage } from './hooks/useLocalStorage';
// NOTE: useFollowupSuggestions is exported from '@qwen-code/webui/followup' export { useFollowupSuggestions } from './hooks/useFollowupSuggestions';
// subpath to avoid forcing all consumers to install @qwen-code/qwen-code-core. export type {
FollowupState,
UseFollowupSuggestionsOptions,
UseFollowupSuggestionsReturn,
} from './hooks/useFollowupSuggestions';
// Types // Types
export type { Theme } from './types/theme'; export type { Theme } from './types/theme';

View file

@ -0,0 +1,20 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export interface FollowupState {
/** Current suggestion text */
suggestion: string | null;
/** Whether to show suggestion */
isVisible: boolean;
/** Timestamp when suggestion was shown (for telemetry) */
shownAt: number;
}
export const INITIAL_FOLLOWUP_STATE: Readonly<FollowupState> = Object.freeze({
suggestion: null,
isVisible: false,
shownAt: 0,
});

View file

@ -1,52 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Separate Vite config for the @qwen-code/webui/followup subpath entry.
*
* Built independently so that the root entry (vite.config.ts) stays free
* of @qwen-code/qwen-code-core and can retain UMD output.
*/
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import { resolve } from 'path';
export default defineConfig({
plugins: [
react(),
dts({
include: ['src/followup.ts', 'src/hooks/useFollowupSuggestions.ts'],
outDir: 'dist',
rollupTypes: false,
// Do not insert types entry — avoid clobbering the main build's index.d.ts
insertTypesEntry: false,
}),
],
build: {
lib: {
entry: resolve(__dirname, 'src/followup.ts'),
formats: ['es', 'cjs'],
fileName: (format) => {
if (format === 'es') return 'followup.js';
if (format === 'cjs') return 'followup.cjs';
return 'followup.js';
},
},
outDir: 'dist',
emptyOutDir: false,
rollupOptions: {
external: [
'react',
'react-dom',
'react/jsx-runtime',
'@qwen-code/qwen-code-core',
],
},
sourcemap: true,
minify: false,
cssCodeSplit: false,
},
});

View file

@ -18,10 +18,6 @@ import { resolve } from 'path';
* - UMD: dist/index.umd.js (for CDN usage) * - UMD: dist/index.umd.js (for CDN usage)
* - TypeScript declarations: dist/index.d.ts * - TypeScript declarations: dist/index.d.ts
* - CSS: dist/styles.css (optional styles) * - CSS: dist/styles.css (optional styles)
*
* The followup subpath (@qwen-code/webui/followup) is built separately
* via vite.config.followup.ts so that the root entry stays free of
* @qwen-code/qwen-code-core dependencies.
*/ */
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [