mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
Merge remote-tracking branch 'origin/main' into feat/review-skill-improvements
This commit is contained in:
commit
16640e92b9
40 changed files with 904 additions and 2358 deletions
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
|
|
@ -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
2035
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -12,10 +12,21 @@ import type {
|
|||
BaseInfo,
|
||||
} 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 {
|
||||
return { channel_version: CHANNEL_VERSION };
|
||||
return { channel_version: ILINK_PROTOCOL_VERSION };
|
||||
}
|
||||
|
||||
function randomUin(): string {
|
||||
|
|
@ -28,6 +39,10 @@ function buildHeaders(token?: string): Record<string, string> {
|
|||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WECHAT-UIN': randomUin(),
|
||||
'iLink-App-Id': 'bot',
|
||||
'iLink-App-ClientVersion': String(
|
||||
buildClientVersion(ILINK_PROTOCOL_VERSION),
|
||||
),
|
||||
};
|
||||
if (token) {
|
||||
headers['AuthorizationType'] = 'ilink_bot_token';
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export enum Command {
|
|||
EXIT = 'exit',
|
||||
SHOW_MORE_LINES = 'showMoreLines',
|
||||
RETRY_LAST = 'retryLast',
|
||||
TOGGLE_VERBOSE_MODE = 'toggleVerboseMode',
|
||||
|
||||
// Shell commands
|
||||
REVERSE_SEARCH = 'reverseSearch',
|
||||
|
|
@ -172,6 +173,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
|||
[Command.EXIT]: [{ key: 'd', ctrl: true }],
|
||||
[Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
|
||||
[Command.RETRY_LAST]: [{ key: 'y', ctrl: true }],
|
||||
[Command.TOGGLE_VERBOSE_MODE]: [{ key: 'o', ctrl: true }],
|
||||
|
||||
// Shell commands
|
||||
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
|
||||
|
|
|
|||
|
|
@ -582,6 +582,16 @@ const SETTINGS_SCHEMA = {
|
|||
description: 'The last time the feedback dialog was shown.',
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1968,4 +1968,9 @@ export default {
|
|||
'Raw-Modus nicht verfügbar. Bitte in einem interaktiven Terminal ausführen.',
|
||||
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2008,4 +2008,9 @@ export default {
|
|||
'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',
|
||||
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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1460,4 +1460,8 @@ export default {
|
|||
'Rawモードが利用できません。インタラクティブターミナルで実行してください。',
|
||||
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\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 で完全なツール出力を表示',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1958,4 +1958,9 @@ export default {
|
|||
'Modo raw não disponível. Execute em um terminal interativo.',
|
||||
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1965,4 +1965,9 @@ export default {
|
|||
'Raw-режим недоступен. Пожалуйста, запустите в интерактивном терминале.',
|
||||
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\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 для показа полного вывода инструментов',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1813,4 +1813,8 @@ export default {
|
|||
'原始模式不可用。请在交互式终端中运行。',
|
||||
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\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 查看详细工具调用结果',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
|
|||
import { useResumeCommand } from './hooks/useResumeCommand.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useVimMode } from './contexts/VimModeContext.js';
|
||||
import { VerboseModeProvider } from './contexts/VerboseModeContext.js';
|
||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
import { calculatePromptWidths } from './components/InputPrompt.js';
|
||||
import { useStdin, useStdout } from 'ink';
|
||||
|
|
@ -963,6 +964,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
handleWelcomeBackClose,
|
||||
} = useWelcomeBack(config, handleFinalSubmit, buffer, settings.merged);
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
|
||||
);
|
||||
|
||||
cancelHandlerRef.current = useCallback(() => {
|
||||
const pendingHistoryItems = [
|
||||
...pendingSlashCommandHistoryItems,
|
||||
|
|
@ -1259,6 +1265,13 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
const [showToolDescriptions, setShowToolDescriptions] =
|
||||
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 ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
|
||||
|
|
@ -1273,6 +1286,18 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
const [showEscapePrompt, setShowEscapePrompt] = 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 } =
|
||||
useFolderTrust(settings, setIsTrustedFolder);
|
||||
const {
|
||||
|
|
@ -1659,6 +1684,18 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
if (activePtyId || embeddedShellFocused) {
|
||||
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,
|
||||
setBtwItem,
|
||||
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,
|
||||
verboseMode,
|
||||
setVerboseMode,
|
||||
setFrozenSnapshot,
|
||||
pendingHistoryItems,
|
||||
refreshStatic,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -1777,11 +1822,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
sessionStats,
|
||||
});
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
|
||||
);
|
||||
|
||||
const uiState: UIState = useMemo(
|
||||
() => ({
|
||||
history: historyManager.history,
|
||||
|
|
@ -2117,6 +2157,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
],
|
||||
);
|
||||
|
||||
const verboseModeValue = useMemo(
|
||||
() => ({ verboseMode, frozenSnapshot }),
|
||||
[verboseMode, frozenSnapshot],
|
||||
);
|
||||
|
||||
return (
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
|
|
@ -2127,9 +2172,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
startupWarnings: props.startupWarnings || [],
|
||||
}}
|
||||
>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
</ShellFocusContext.Provider>
|
||||
<VerboseModeProvider value={verboseModeValue}>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
</ShellFocusContext.Provider>
|
||||
</VerboseModeProvider>
|
||||
</AppContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</UIActionsContext.Provider>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
|||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useVerboseMode } from '../contexts/VerboseModeContext.js';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ export const Footer: React.FC = () => {
|
|||
const uiState = useUIState();
|
||||
const config = useConfig();
|
||||
const { vimEnabled, vimMode } = useVimMode();
|
||||
const { verboseMode } = useVerboseMode();
|
||||
|
||||
const { promptTokenCount, showAutoAcceptIndicator } = {
|
||||
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 (
|
||||
<Box
|
||||
justifyContent="space-between"
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import { ContextUsage } from './views/ContextUsage.js';
|
|||
import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js';
|
||||
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
|
||||
import { BtwMessage } from './messages/BtwMessage.js';
|
||||
import { useVerboseMode } from '../contexts/VerboseModeContext.js';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
|
|
@ -79,6 +80,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
? 0
|
||||
: 1;
|
||||
|
||||
const { verboseMode } = useVerboseMode();
|
||||
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
|
||||
const contentWidth = terminalWidth - 4;
|
||||
const boxWidth = mainAreaWidth || contentWidth;
|
||||
|
|
@ -118,7 +120,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
contentWidth={contentWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_thought' && (
|
||||
{verboseMode && itemForDisplay.type === 'gemini_thought' && (
|
||||
<ThinkMessage
|
||||
text={itemForDisplay.text}
|
||||
isPending={isPending}
|
||||
|
|
@ -128,7 +130,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
contentWidth={contentWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_thought_content' && (
|
||||
{verboseMode && itemForDisplay.type === 'gemini_thought_content' && (
|
||||
<ThinkMessageContent
|
||||
text={itemForDisplay.text}
|
||||
isPending={isPending}
|
||||
|
|
@ -183,6 +185,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
isFocused={isFocused}
|
||||
activeShellPtyId={activeShellPtyId}
|
||||
embeddedShellFocused={embeddedShellFocused}
|
||||
isUserInitiated={itemForDisplay.isUserInitiated}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'compression' && (
|
||||
|
|
|
|||
|
|
@ -2418,36 +2418,6 @@ describe('InputPrompt', () => {
|
|||
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 () => {
|
||||
// Set up mocks that actually update buffer state
|
||||
vi.mocked(mockBuffer.insert).mockImplementation((text: string) => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
|||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { AppHeader } from './AppHeader.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
|
||||
// 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 = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const { frozenSnapshot } = useVerboseMode();
|
||||
const {
|
||||
pendingHistoryItems,
|
||||
terminalWidth,
|
||||
|
|
@ -57,21 +59,26 @@ export const MainContent = () => {
|
|||
</Static>
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
{pendingHistoryItems.map((item, i) => (
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
mainAreaWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
/>
|
||||
))}
|
||||
{(frozenSnapshot ?? pendingHistoryItems).map((item, i) => {
|
||||
const isFrozen = frozenSnapshot !== null;
|
||||
return (
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
mainAreaWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
isFocused={isFrozen ? false : !uiState.isEditorDialogOpen}
|
||||
activeShellPtyId={isFrozen ? undefined : uiState.activePtyId}
|
||||
embeddedShellFocused={
|
||||
isFrozen ? false : uiState.embeddedShellFocused
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// 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"`;
|
||||
|
|
|
|||
|
|
@ -173,34 +173,6 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
);
|
||||
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 () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -11,9 +11,11 @@ import type { IndividualToolCallDisplay } from '../../types.js';
|
|||
import { ToolCallStatus } from '../../types.js';
|
||||
import { ToolMessage } from './ToolMessage.js';
|
||||
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import { CompactToolGroupDisplay } from './CompactToolGroupDisplay.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { useVerboseMode } from '../../contexts/VerboseModeContext.js';
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
groupId: number;
|
||||
|
|
@ -24,6 +26,7 @@ interface ToolGroupMessageProps {
|
|||
activeShellPtyId?: number | null;
|
||||
embeddedShellFocused?: boolean;
|
||||
onShellInputSubmit?: (input: string) => void;
|
||||
isUserInitiated?: boolean;
|
||||
}
|
||||
|
||||
// Main component renders the border and maps the tools using ToolMessage
|
||||
|
|
@ -34,7 +37,15 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||
isFocused = true,
|
||||
activeShellPtyId,
|
||||
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 =
|
||||
embeddedShellFocused &&
|
||||
toolCalls.some(
|
||||
|
|
@ -42,11 +53,36 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||
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(
|
||||
(t) => t.status === ToolCallStatus.Success,
|
||||
);
|
||||
|
||||
const config = useConfig();
|
||||
const isShellCommand = toolCalls.some(
|
||||
(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)
|
||||
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;
|
||||
for (const tool of toolCalls) {
|
||||
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
|
||||
|
|
@ -121,6 +150,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||
activeShellPtyId={activeShellPtyId}
|
||||
embeddedShellFocused={embeddedShellFocused}
|
||||
config={config}
|
||||
forceShowResult={
|
||||
isUserInitiated ||
|
||||
tool.status === ToolCallStatus.Confirming ||
|
||||
tool.status === ToolCallStatus.Error
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
{tool.status === ToolCallStatus.Confirming &&
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { StreamingState, ToolCallStatus } from '../../types.js';
|
|||
import { Text } from 'ink';
|
||||
import { StreamingContext } from '../../contexts/StreamingContext.js';
|
||||
import { SettingsContext } from '../../contexts/SettingsContext.js';
|
||||
import { VerboseModeProvider } from '../../contexts/VerboseModeContext.js';
|
||||
import type {
|
||||
AnsiOutput,
|
||||
AnsiOutputDisplay,
|
||||
|
|
@ -101,18 +102,21 @@ const mockSettings: LoadedSettings = {
|
|||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
// Helper to render with context
|
||||
// Helper to render with context (verbose=true by default to show tool output)
|
||||
const renderWithContext = (
|
||||
ui: React.ReactElement,
|
||||
streamingState: StreamingState,
|
||||
verboseMode = true,
|
||||
) => {
|
||||
const contextValue: StreamingState = streamingState;
|
||||
return render(
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<StreamingContext.Provider value={contextValue}>
|
||||
{ui}
|
||||
</StreamingContext.Provider>
|
||||
</SettingsContext.Provider>,
|
||||
<VerboseModeProvider value={{ verboseMode, frozenSnapshot: null }}>
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<StreamingContext.Provider value={contextValue}>
|
||||
{ui}
|
||||
</StreamingContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</VerboseModeProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -143,6 +147,18 @@ describe('<ToolMessage />', () => {
|
|||
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', () => {
|
||||
it('shows ✓ for Success status', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { ToolCallStatus } from '../../types.js';
|
|||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { AnsiOutputText } from '../AnsiOutput.js';
|
||||
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { TodoDisplay } from '../TodoDisplay.js';
|
||||
import type {
|
||||
|
|
@ -25,18 +24,19 @@ import type {
|
|||
import { AgentExecutionDisplay } from '../subagents/index.js';
|
||||
import { PlanSummaryDisplay } from '../PlanSummaryDisplay.js';
|
||||
import { ShellInputPrompt } from '../ShellInputPrompt.js';
|
||||
import {
|
||||
SHELL_COMMAND_NAME,
|
||||
SHELL_NAME,
|
||||
TOOL_STATUS,
|
||||
} from '../../constants.js';
|
||||
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.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 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
|
||||
|
||||
// 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;
|
||||
embeddedShellFocused?: boolean;
|
||||
config?: Config;
|
||||
forceShowResult?: boolean;
|
||||
}
|
||||
|
||||
export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
|
|
@ -263,10 +264,11 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||
embeddedShellFocused,
|
||||
ptyId,
|
||||
config,
|
||||
forceShowResult,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const isThisShellFocused =
|
||||
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
|
||||
(name === SHELL_COMMAND_NAME || name === SHELL_NAME) &&
|
||||
status === ToolCallStatus.Executing &&
|
||||
ptyId === activeShellPtyId &&
|
||||
embeddedShellFocused;
|
||||
|
|
@ -300,7 +302,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||
}, [isThisShellFocused]);
|
||||
|
||||
const isThisShellFocusable =
|
||||
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
|
||||
(name === SHELL_COMMAND_NAME || name === SHELL_NAME) &&
|
||||
status === ToolCallStatus.Executing &&
|
||||
config?.getShouldUseNodePtyShell();
|
||||
|
||||
|
|
@ -324,6 +326,11 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||
|
||||
// Use the custom hook to determine the display type
|
||||
const displayRenderer = useResultDisplayRenderer(resultDisplay);
|
||||
const { verboseMode } = useVerboseMode();
|
||||
const effectiveDisplayRenderer =
|
||||
verboseMode || forceShowResult
|
||||
? displayRenderer
|
||||
: { type: 'none' as const };
|
||||
|
||||
return (
|
||||
<Box paddingX={1} paddingY={0} flexDirection="column">
|
||||
|
|
@ -344,44 +351,44 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||
)}
|
||||
{emphasis === 'high' && <TrailingIndicator />}
|
||||
</Box>
|
||||
{displayRenderer.type !== 'none' && (
|
||||
{effectiveDisplayRenderer.type !== 'none' && (
|
||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}>
|
||||
<Box flexDirection="column">
|
||||
{displayRenderer.type === 'todo' && (
|
||||
<TodoResultRenderer data={displayRenderer.data} />
|
||||
{effectiveDisplayRenderer.type === 'todo' && (
|
||||
<TodoResultRenderer data={effectiveDisplayRenderer.data} />
|
||||
)}
|
||||
{displayRenderer.type === 'plan' && (
|
||||
{effectiveDisplayRenderer.type === 'plan' && (
|
||||
<PlanResultRenderer
|
||||
data={displayRenderer.data}
|
||||
data={effectiveDisplayRenderer.data}
|
||||
availableHeight={availableHeight}
|
||||
childWidth={innerWidth}
|
||||
/>
|
||||
)}
|
||||
{displayRenderer.type === 'task' && config && (
|
||||
{effectiveDisplayRenderer.type === 'task' && config && (
|
||||
<SubagentExecutionRenderer
|
||||
data={displayRenderer.data}
|
||||
data={effectiveDisplayRenderer.data}
|
||||
availableHeight={availableHeight}
|
||||
childWidth={innerWidth}
|
||||
config={config}
|
||||
/>
|
||||
)}
|
||||
{displayRenderer.type === 'diff' && (
|
||||
{effectiveDisplayRenderer.type === 'diff' && (
|
||||
<DiffResultRenderer
|
||||
data={displayRenderer.data}
|
||||
data={effectiveDisplayRenderer.data}
|
||||
availableHeight={availableHeight}
|
||||
childWidth={innerWidth}
|
||||
settings={settings}
|
||||
/>
|
||||
)}
|
||||
{displayRenderer.type === 'ansi' && (
|
||||
{effectiveDisplayRenderer.type === 'ansi' && (
|
||||
<AnsiOutputText
|
||||
data={displayRenderer.data}
|
||||
data={effectiveDisplayRenderer.data}
|
||||
availableTerminalHeight={availableHeight}
|
||||
/>
|
||||
)}
|
||||
{displayRenderer.type === 'string' && (
|
||||
{effectiveDisplayRenderer.type === 'string' && (
|
||||
<StringResultRenderer
|
||||
data={displayRenderer.data}
|
||||
data={effectiveDisplayRenderer.data}
|
||||
renderAsMarkdown={renderOutputAsMarkdown}
|
||||
availableHeight={availableHeight}
|
||||
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 = {
|
||||
name: string;
|
||||
description: string;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
23
packages/cli/src/ui/contexts/VerboseModeContext.tsx
Normal file
23
packages/cli/src/ui/contexts/VerboseModeContext.tsx
Normal 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;
|
||||
|
|
@ -143,6 +143,7 @@ describe('useShellCommandProcessor', () => {
|
|||
status: ToolCallStatus.Executing,
|
||||
}),
|
||||
],
|
||||
isUserInitiated: true,
|
||||
});
|
||||
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
|
||||
const wrappedCommand = `{ ls -l; }; __code=$?; pwd > "${tmpFile}"; exit $__code`;
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ export const useShellCommandProcessor = (
|
|||
setPendingHistoryItem({
|
||||
type: 'tool_group',
|
||||
tools: [initialToolDisplay],
|
||||
isUserInitiated: true,
|
||||
});
|
||||
|
||||
let executionPid: number | undefined;
|
||||
|
|
@ -304,6 +305,7 @@ export const useShellCommandProcessor = (
|
|||
{
|
||||
type: 'tool_group',
|
||||
tools: [finalToolDisplay],
|
||||
isUserInitiated: true,
|
||||
} as HistoryItemWithoutId,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ describe('keyMatchers', () => {
|
|||
[Command.EXIT]: (key: Key) => key.ctrl && key.name === 'd',
|
||||
[Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's',
|
||||
[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.SUBMIT_REVERSE_SEARCH]: (key: Key) =>
|
||||
key.name === 'return' && !key.ctrl,
|
||||
|
|
@ -258,6 +259,11 @@ describe('keyMatchers', () => {
|
|||
positive: [createKey('y', { 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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ export type HistoryItemQuit = HistoryItemBase & {
|
|||
export type HistoryItemToolGroup = HistoryItemBase & {
|
||||
type: 'tool_group';
|
||||
tools: IndividualToolCallDisplay[];
|
||||
isUserInitiated?: boolean;
|
||||
};
|
||||
|
||||
export type HistoryItemUserShell = HistoryItemBase & {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
it('should support JSON Schema draft-2020-12', () => {
|
||||
const schema = {
|
||||
|
|
@ -280,6 +406,29 @@ describe('SchemaValidator', () => {
|
|||
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', () => {
|
||||
// draft-2019-09 is not supported by Ajv by default
|
||||
const schema = {
|
||||
|
|
|
|||
|
|
@ -102,6 +102,13 @@ export class SchemaValidator {
|
|||
if (!valid && validate.errors) {
|
||||
// Coerce string boolean values ("true"/"false") to actual booleans
|
||||
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);
|
||||
if (!valid && validate.errors) {
|
||||
|
|
@ -121,6 +128,92 @@ export class SchemaValidator {
|
|||
* - "true", "True", "TRUE" -> true
|
||||
* - "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>) {
|
||||
for (const key of Object.keys(data)) {
|
||||
if (!(key in data)) continue;
|
||||
|
|
|
|||
|
|
@ -207,7 +207,6 @@
|
|||
"@types/vscode": "^1.85.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"@vscode/vsce": "^3.6.0",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"esbuild": "^0.25.3",
|
||||
"eslint": "^9.25.1",
|
||||
|
|
|
|||
|
|
@ -214,6 +214,11 @@
|
|||
"description": "The last time the feedback dialog was shown.",
|
||||
"type": "number",
|
||||
"default": 0
|
||||
},
|
||||
"verboseMode": {
|
||||
"description": "Show full tool output and thinking in verbose mode (toggle with Ctrl+O).",
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,11 +12,6 @@
|
|||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./followup": {
|
||||
"types": "./dist/followup.d.ts",
|
||||
"import": "./dist/followup.js",
|
||||
"require": "./dist/followup.cjs"
|
||||
},
|
||||
"./icons": {
|
||||
"types": "./dist/components/icons/index.d.ts",
|
||||
"import": "./dist/components/icons/index.js",
|
||||
|
|
@ -37,7 +32,7 @@
|
|||
},
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build && vite build --config vite.config.followup.ts",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
||||
|
|
@ -45,15 +40,9 @@
|
|||
"build-storybook": "storybook build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@qwen-code/qwen-code-core": ">=0.13.1",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@qwen-code/qwen-code-core": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"markdown-it": "^14.1.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,18 +22,7 @@ import { CompletionMenu } from './CompletionMenu.js';
|
|||
import { ContextIndicator } from './ContextIndicator.js';
|
||||
import type { CompletionItem } from '../../types/completion.js';
|
||||
import type { ContextUsage } from './ContextIndicator.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;
|
||||
}
|
||||
import type { FollowupState } from '../../types/followup.js';
|
||||
|
||||
/**
|
||||
* Edit mode display information
|
||||
|
|
@ -141,7 +130,7 @@ export interface InputFormProps {
|
|||
/** Whether the current draft is eligible to submit */
|
||||
canSubmit?: boolean;
|
||||
/** Prompt suggestion state */
|
||||
followupState?: InputFormFollowupState;
|
||||
followupState?: FollowupState;
|
||||
/** Callback to accept prompt suggestion */
|
||||
onAcceptFollowup?: (method?: 'tab' | 'enter' | 'right') => void;
|
||||
/** Callback to dismiss prompt suggestion */
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -2,34 +2,27 @@
|
|||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* 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 {
|
||||
INITIAL_FOLLOWUP_STATE,
|
||||
createFollowupController,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { FollowupState } from '@qwen-code/qwen-code-core';
|
||||
import type { FollowupState } from '../types/followup.js';
|
||||
import { INITIAL_FOLLOWUP_STATE } from '../types/followup.js';
|
||||
|
||||
// Re-export types from core for convenience
|
||||
export type { FollowupState } from '@qwen-code/qwen-code-core';
|
||||
export type { FollowupState } from '../types/followup.js';
|
||||
|
||||
/**
|
||||
* Options for the hook
|
||||
*/
|
||||
export interface UseFollowupSuggestionsOptions {
|
||||
/** Whether the feature is enabled */
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller (framework-agnostic)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 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;
|
||||
/** Callback when suggestion is accepted */
|
||||
onAccept?: (suggestion: string) => void;
|
||||
/** Callback when a suggestion outcome is determined */
|
||||
onStateChange: (state: FollowupState) => void;
|
||||
getOnAccept?: () => ((text: string) => void) | undefined;
|
||||
onOutcome?: (params: {
|
||||
outcome: 'accepted' | 'ignored';
|
||||
accept_method?: 'tab' | 'enter' | 'right';
|
||||
|
|
@ -38,31 +31,175 @@ export interface UseFollowupSuggestionsOptions {
|
|||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned by the hook
|
||||
*/
|
||||
export interface UseFollowupSuggestionsReturn {
|
||||
/** Current state */
|
||||
state: FollowupState;
|
||||
/** Get current placeholder text */
|
||||
getPlaceholder: (defaultPlaceholder: string) => string;
|
||||
/** Set suggestion text (called by parent component) */
|
||||
interface FollowupControllerActions {
|
||||
setSuggestion: (text: string | null) => void;
|
||||
accept: (method?: 'tab' | 'enter' | 'right') => void;
|
||||
dismiss: () => void;
|
||||
clear: () => void;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
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;
|
||||
/** Accept the current suggestion */
|
||||
accept: (method?: 'tab' | 'enter' | 'right') => void;
|
||||
/** Dismiss the current suggestion */
|
||||
dismiss: () => void;
|
||||
/** Clear all state */
|
||||
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(
|
||||
options: UseFollowupSuggestionsOptions = {},
|
||||
): UseFollowupSuggestionsReturn {
|
||||
|
|
@ -70,13 +207,11 @@ export function useFollowupSuggestions(
|
|||
|
||||
const [state, setState] = useState<FollowupState>(INITIAL_FOLLOWUP_STATE);
|
||||
|
||||
// Keep mutable refs so the controller always sees the latest callbacks
|
||||
const onAcceptRef = useRef(onAccept);
|
||||
onAcceptRef.current = onAccept;
|
||||
const onOutcomeRef = useRef(onOutcome);
|
||||
onOutcomeRef.current = onOutcome;
|
||||
|
||||
// Create the controller once — it is stable across renders
|
||||
const controller = useMemo(
|
||||
() =>
|
||||
createFollowupController({
|
||||
|
|
@ -88,7 +223,6 @@ export function useFollowupSuggestions(
|
|||
[enabled],
|
||||
);
|
||||
|
||||
// Clear state when disabled; clean up timers on unmount
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
controller.clear();
|
||||
|
|
@ -96,7 +230,6 @@ export function useFollowupSuggestions(
|
|||
return () => controller.cleanup();
|
||||
}, [controller, enabled]);
|
||||
|
||||
// WebUI-specific helper: resolves placeholder text
|
||||
const getPlaceholder = useCallback(
|
||||
(defaultPlaceholder: string) => {
|
||||
if (state.isVisible && state.suggestion) {
|
||||
|
|
|
|||
|
|
@ -231,8 +231,12 @@ export { StopIcon } from './components/icons/StopIcon';
|
|||
// Hooks
|
||||
export { useTheme } from './hooks/useTheme';
|
||||
export { useLocalStorage } from './hooks/useLocalStorage';
|
||||
// NOTE: useFollowupSuggestions is exported from '@qwen-code/webui/followup'
|
||||
// subpath to avoid forcing all consumers to install @qwen-code/qwen-code-core.
|
||||
export { useFollowupSuggestions } from './hooks/useFollowupSuggestions';
|
||||
export type {
|
||||
FollowupState,
|
||||
UseFollowupSuggestionsOptions,
|
||||
UseFollowupSuggestionsReturn,
|
||||
} from './hooks/useFollowupSuggestions';
|
||||
|
||||
// Types
|
||||
export type { Theme } from './types/theme';
|
||||
|
|
|
|||
20
packages/webui/src/types/followup.ts
Normal file
20
packages/webui/src/types/followup.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -18,10 +18,6 @@ import { resolve } from 'path';
|
|||
* - UMD: dist/index.umd.js (for CDN usage)
|
||||
* - TypeScript declarations: dist/index.d.ts
|
||||
* - 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({
|
||||
plugins: [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue