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,
|
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';
|
||||||
|
|
|
||||||
|
|
@ -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 }],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 で完全なツール出力を表示',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 для показа полного вывода инструментов',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 查看详细工具调用结果',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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' && (
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"`;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 &&
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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`;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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 & {
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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
|
* @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) {
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
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)
|
* - 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: [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue