mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-04 22:51:08 +00:00
Merge pull request #2776 from QwenLM/feat/enhance-btw-command
feat(cli): enhance /btw side question with improved prompt and Ctrl+C/D cancel
This commit is contained in:
parent
61bc80fe19
commit
e855229453
16 changed files with 351 additions and 57 deletions
|
|
@ -599,6 +599,10 @@ export default {
|
|||
'Loading hooks...': 'Hooks werden geladen...',
|
||||
'Error loading hooks:': 'Fehler beim Laden der Hooks:',
|
||||
'Press Escape to close': 'Escape zum Schließen drücken',
|
||||
'Press Escape, Ctrl+C, or Ctrl+D to cancel':
|
||||
'Escape, Ctrl+C oder Ctrl+D zum Abbrechen',
|
||||
'Press Space, Enter, or Escape to dismiss':
|
||||
'Leertaste, Enter oder Escape zum Schließen',
|
||||
'No hook selected': 'Kein Hook ausgewählt',
|
||||
// Hooks - List Step
|
||||
'No hook events found.': 'Keine Hook-Ereignisse gefunden.',
|
||||
|
|
|
|||
|
|
@ -673,6 +673,10 @@ export default {
|
|||
'Loading hooks...': 'Loading hooks...',
|
||||
'Error loading hooks:': 'Error loading hooks:',
|
||||
'Press Escape to close': 'Press Escape to close',
|
||||
'Press Escape, Ctrl+C, or Ctrl+D to cancel':
|
||||
'Press Escape, Ctrl+C, or Ctrl+D to cancel',
|
||||
'Press Space, Enter, or Escape to dismiss':
|
||||
'Press Space, Enter, or Escape to dismiss',
|
||||
'No hook selected': 'No hook selected',
|
||||
// Hooks - List Step
|
||||
'No hook events found.': 'No hook events found.',
|
||||
|
|
|
|||
|
|
@ -385,6 +385,9 @@ export default {
|
|||
'Loading hooks...': 'フックを読み込んでいます...',
|
||||
'Error loading hooks:': 'フックの読み込みエラー:',
|
||||
'Press Escape to close': 'Escape キーで閉じる',
|
||||
'Press Escape, Ctrl+C, or Ctrl+D to cancel':
|
||||
'Escape、Ctrl+C、Ctrl+D でキャンセル',
|
||||
'Press Space, Enter, or Escape to dismiss': 'Space、Enter、Escape で閉じる',
|
||||
'No hook selected': 'フックが選択されていません',
|
||||
// Hooks - List Step
|
||||
'No hook events found.': 'フックイベントが見つかりません。',
|
||||
|
|
|
|||
|
|
@ -604,6 +604,10 @@ export default {
|
|||
'Loading hooks...': 'Carregando hooks...',
|
||||
'Error loading hooks:': 'Erro ao carregar hooks:',
|
||||
'Press Escape to close': 'Pressione Escape para fechar',
|
||||
'Press Escape, Ctrl+C, or Ctrl+D to cancel':
|
||||
'Pressione Escape, Ctrl+C ou Ctrl+D para cancelar',
|
||||
'Press Space, Enter, or Escape to dismiss':
|
||||
'Pressione Espaço, Enter ou Escape para dispensar',
|
||||
'No hook selected': 'Nenhum hook selecionado',
|
||||
// Hooks - List Step
|
||||
'No hook events found.': 'Nenhum evento de hook encontrado.',
|
||||
|
|
|
|||
|
|
@ -610,6 +610,10 @@ export default {
|
|||
'Loading hooks...': 'Загрузка хуков...',
|
||||
'Error loading hooks:': 'Ошибка загрузки хуков:',
|
||||
'Press Escape to close': 'Нажмите Escape для закрытия',
|
||||
'Press Escape, Ctrl+C, or Ctrl+D to cancel':
|
||||
'Нажмите Escape, Ctrl+C или Ctrl+D для отмены',
|
||||
'Press Space, Enter, or Escape to dismiss':
|
||||
'Нажмите Пробел, Enter или Escape для закрытия',
|
||||
'No hook selected': 'Хук не выбран',
|
||||
// Hooks - List Step
|
||||
'No hook events found.': 'События хуков не найдены.',
|
||||
|
|
|
|||
|
|
@ -637,6 +637,9 @@ export default {
|
|||
'Loading hooks...': '正在加载 Hook...',
|
||||
'Error loading hooks:': '加载 Hook 出错:',
|
||||
'Press Escape to close': '按 Escape 关闭',
|
||||
'Press Escape, Ctrl+C, or Ctrl+D to cancel':
|
||||
'按 Escape、Ctrl+C 或 Ctrl+D 取消',
|
||||
'Press Space, Enter, or Escape to dismiss': '按空格、回车或 Escape 关闭',
|
||||
'No hook selected': '未选择 Hook',
|
||||
// Hooks - List Step
|
||||
'No hook events found.': '未找到 Hook 事件。',
|
||||
|
|
|
|||
|
|
@ -1197,13 +1197,19 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
return; // Dialog closed, end processing
|
||||
}
|
||||
|
||||
// 3. Cancel ongoing requests
|
||||
// 3. Cancel in-flight btw side-question
|
||||
if (btwItem && btwItem.btw.isPending && !dialogsVisibleRef.current) {
|
||||
cancelBtw();
|
||||
return; // Btw cancelled, end processing
|
||||
}
|
||||
|
||||
// 4. Cancel ongoing requests
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
cancelOngoingRequest?.();
|
||||
return; // Request cancelled, end processing
|
||||
}
|
||||
|
||||
// 4. Clear input buffer (if has content)
|
||||
// 5. Clear input buffer (if has content)
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
return; // Input cleared, end processing
|
||||
|
|
@ -1219,6 +1225,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
isAuthDialogOpen,
|
||||
handleSlashCommand,
|
||||
closeAnyOpenDialog,
|
||||
btwItem,
|
||||
cancelBtw,
|
||||
streamingState,
|
||||
cancelOngoingRequest,
|
||||
buffer,
|
||||
|
|
@ -1250,6 +1258,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
|
||||
return;
|
||||
} else if (keyMatchers[Command.EXIT](key)) {
|
||||
// Cancel in-flight btw even when buffer has text (Ctrl+D)
|
||||
if (btwItem && btwItem.btw.isPending && !dialogsVisibleRef.current) {
|
||||
cancelBtw();
|
||||
return;
|
||||
}
|
||||
if (buffer.text.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1318,6 +1331,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Note: Ctrl+C/D btw cancellation is handled inside handleExit
|
||||
// (step 3), not here, because Command.QUIT/EXIT match first.
|
||||
|
||||
let enteringConstrainHeightMode = false;
|
||||
if (!constrainHeight) {
|
||||
enteringConstrainHeightMode = true;
|
||||
|
|
|
|||
|
|
@ -186,6 +186,55 @@ describe('btwCommand', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should trim history to last 20 messages for long conversations', async () => {
|
||||
// Build 24 history entries — exceeds the 20-message limit
|
||||
const longHistory = Array.from({ length: 12 }, (_, i) => [
|
||||
{ role: 'user', parts: [{ text: `Q${i}` }] },
|
||||
{ role: 'model', parts: [{ text: `A${i}` }] },
|
||||
]).flat();
|
||||
mockGetHistory.mockReturnValue(longHistory);
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
|
||||
});
|
||||
|
||||
await btwCommand.action!(mockContext, 'test');
|
||||
await flushPromises();
|
||||
|
||||
const calledContents = mockGenerateContent.mock.calls[0][0];
|
||||
// 20 history entries + 1 btw question = 21
|
||||
expect(calledContents).toHaveLength(21);
|
||||
// First entry should be user (Q2, since slice(-20) on 24 starts at index 4)
|
||||
expect(calledContents[0].role).toBe('user');
|
||||
expect(calledContents[0].parts[0].text).toBe('Q2');
|
||||
});
|
||||
|
||||
it('should trim history and skip leading model entry to preserve alternation', async () => {
|
||||
// Build 21 entries: 10 full turns + 1 trailing user message.
|
||||
// slice(-20) yields [M0, U1, M1, ..., U9, M9, U10] — starts with model.
|
||||
// trimHistory should drop that leading model entry.
|
||||
const oddHistory = [
|
||||
...Array.from({ length: 11 }, (_, i) => [
|
||||
{ role: 'user', parts: [{ text: `Q${i}` }] },
|
||||
{ role: 'model', parts: [{ text: `A${i}` }] },
|
||||
]).flat(),
|
||||
].slice(0, 21); // [U0, M0, U1, M1, ..., U9, M9, U10]
|
||||
expect(oddHistory).toHaveLength(21);
|
||||
|
||||
mockGetHistory.mockReturnValue(oddHistory);
|
||||
mockGenerateContent.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'answer' }] } }],
|
||||
});
|
||||
|
||||
await btwCommand.action!(mockContext, 'test');
|
||||
await flushPromises();
|
||||
|
||||
const calledContents = mockGenerateContent.mock.calls[0][0];
|
||||
// slice(-20) = 20 entries starting with M0 (model) → slice(1) = 19, + 1 btw = 20
|
||||
expect(calledContents).toHaveLength(20);
|
||||
expect(calledContents[0].role).toBe('user');
|
||||
expect(calledContents[0].parts[0].text).toBe('Q1');
|
||||
});
|
||||
|
||||
it('should add error item on failure and clear btwItem', async () => {
|
||||
mockGenerateContent.mockRejectedValue(new Error('API error'));
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { MessageType } from '../types.js';
|
|||
import type { HistoryItemBtw } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import type { GeminiClient } from '@qwen-code/qwen-code-core';
|
||||
import type { Content } from '@google/genai';
|
||||
|
||||
function makeBtwPromptId(sessionId: string): string {
|
||||
return `${sessionId}########btw-${Date.now()}`;
|
||||
|
|
@ -26,6 +27,24 @@ function formatBtwError(error: unknown): string {
|
|||
});
|
||||
}
|
||||
|
||||
// Keep only the most recent history messages to limit token usage for side
|
||||
// questions. MAX_BTW_HISTORY_MESSAGES caps the number of history Content
|
||||
// entries included as context before the /btw question is appended.
|
||||
const MAX_BTW_HISTORY_MESSAGES = 20;
|
||||
|
||||
function trimHistory(history: Content[]): Content[] {
|
||||
if (history.length <= MAX_BTW_HISTORY_MESSAGES) {
|
||||
return history;
|
||||
}
|
||||
// Slice from the end, ensuring we start on a 'user' message so the
|
||||
// alternating user/model pattern is preserved.
|
||||
const sliced = history.slice(-MAX_BTW_HISTORY_MESSAGES);
|
||||
if (sliced[0]?.role === 'model' && sliced.length > 1) {
|
||||
return sliced.slice(1);
|
||||
}
|
||||
return sliced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to make the ephemeral generateContent call and extract the answer.
|
||||
* Uses a snapshot of the current conversation history as context.
|
||||
|
|
@ -37,8 +56,13 @@ async function askBtw(
|
|||
abortSignal: AbortSignal,
|
||||
promptId: string,
|
||||
): Promise<string> {
|
||||
const history = geminiClient.getHistory();
|
||||
const history = trimHistory(geminiClient.getHistory(true));
|
||||
|
||||
// Side-question guidance sent as a user message (not a system instruction).
|
||||
// Inspired by Claude Code's design:
|
||||
// - Emphasizes direct answering without tools
|
||||
// - Clarifies the isolated nature of the side question
|
||||
// - Prevents the model from promising actions it can't take
|
||||
const response = await geminiClient.generateContent(
|
||||
[
|
||||
...history,
|
||||
|
|
@ -46,7 +70,23 @@ async function askBtw(
|
|||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: `[Side question - answer briefly and concisely, this is a "by the way" question that doesn't need to be part of our main conversation]\n\n${question}`,
|
||||
text: `[This is a side question - answer directly and concisely.
|
||||
|
||||
IMPORTANT:
|
||||
- You are a separate, lightweight agent spawned to answer this one question
|
||||
- The main conversation continues independently in the background
|
||||
- Do NOT reference being interrupted or what you were "previously doing"
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
- You have NO tools available - you cannot read files, run commands, search, or take any actions
|
||||
- This is a one-off response in a single turn
|
||||
- You can ONLY provide information based on what you already know from the conversation context
|
||||
- NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action
|
||||
- If you don't know the answer, say so - do not offer to look it up or investigate
|
||||
|
||||
Simply answer the question directly with the information you have.]
|
||||
|
||||
${question}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -235,7 +235,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
<InsightProgressMessage progress={itemForDisplay.progress} />
|
||||
)}
|
||||
{itemForDisplay.type === 'btw' && itemForDisplay.btw && (
|
||||
<BtwMessage btw={itemForDisplay.btw} />
|
||||
<BtwMessage btw={itemForDisplay.btw} containerWidth={contentWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'user_prompt_submit_blocked' && (
|
||||
<Box flexDirection="column">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { BtwMessage } from './BtwMessage.js';
|
||||
|
||||
describe('BtwMessage', () => {
|
||||
|
|
@ -16,7 +16,7 @@ describe('BtwMessage', () => {
|
|||
});
|
||||
|
||||
it('renders the side question and answer', () => {
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<BtwMessage
|
||||
btw={{
|
||||
question: 'side question',
|
||||
|
|
@ -31,4 +31,57 @@ describe('BtwMessage', () => {
|
|||
expect(output).toContain('side question');
|
||||
expect(output).toContain('side answer');
|
||||
});
|
||||
|
||||
it('renders pending state with cancel hint', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<BtwMessage
|
||||
btw={{
|
||||
question: 'pending question',
|
||||
answer: '',
|
||||
isPending: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame() ?? '';
|
||||
expect(output).toContain('/btw');
|
||||
expect(output).toContain('pending question');
|
||||
expect(output).toContain('Answering...');
|
||||
expect(output).toContain('Ctrl+C');
|
||||
expect(output).toContain('Ctrl+D');
|
||||
});
|
||||
|
||||
it('accepts containerWidth prop for content width calculation', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<BtwMessage
|
||||
btw={{
|
||||
question: 'q',
|
||||
answer: 'some answer text',
|
||||
isPending: false,
|
||||
}}
|
||||
containerWidth={60}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame() ?? '';
|
||||
expect(output).toContain('some answer text');
|
||||
});
|
||||
|
||||
it('renders dismiss hint when answer is complete', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<BtwMessage
|
||||
btw={{
|
||||
question: 'q',
|
||||
answer: 'a',
|
||||
isPending: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame() ?? '';
|
||||
expect(output).toContain('Space');
|
||||
expect(output).toContain('Enter');
|
||||
expect(output).toContain('Escape');
|
||||
expect(output).toContain('dismiss');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,46 +9,80 @@ import { Box, Text } from 'ink';
|
|||
import type { BtwProps } from '../../types.js';
|
||||
import { Colors } from '../../colors.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
|
||||
export interface BtwDisplayProps {
|
||||
btw: BtwProps;
|
||||
/** Width of the parent container. Used to compute Markdown content width.
|
||||
* Falls back to terminal width when not provided. */
|
||||
containerWidth?: number;
|
||||
}
|
||||
|
||||
const BtwMessageInternal: React.FC<BtwDisplayProps> = ({ btw }) => (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentYellow}
|
||||
paddingX={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box flexDirection="row">
|
||||
<Text color={Colors.AccentYellow} bold>
|
||||
{'/btw '}
|
||||
</Text>
|
||||
<Text wrap="wrap" color={Colors.AccentYellow}>
|
||||
{btw.question}
|
||||
</Text>
|
||||
// border(1)*2 + paddingX(1)*2 = 4
|
||||
const BTW_SELF_CHROME = 4;
|
||||
|
||||
/**
|
||||
* Ensure code fences (``` or ~~~) start on their own line so that
|
||||
* MarkdownDisplay's line-based parser can detect them. Models sometimes
|
||||
* emit the opening fence right after prose text without a preceding newline.
|
||||
*/
|
||||
function normalizeCodeFences(text: string): string {
|
||||
return text.replace(/([^\n])(```|~~~)/g, '$1\n$2');
|
||||
}
|
||||
|
||||
const BtwMessageInternal: React.FC<BtwDisplayProps> = ({
|
||||
btw,
|
||||
containerWidth,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const baseWidth = containerWidth ?? terminalWidth;
|
||||
const contentWidth = Math.max(2, baseWidth - BTW_SELF_CHROME);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentYellow}
|
||||
paddingX={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box flexDirection="row">
|
||||
<Text color={Colors.AccentYellow} bold>
|
||||
{'/btw '}
|
||||
</Text>
|
||||
<Text wrap="wrap" color={Colors.AccentYellow}>
|
||||
{btw.question}
|
||||
</Text>
|
||||
</Box>
|
||||
{btw.isPending ? (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color={Colors.AccentYellow}>{'+ '}</Text>
|
||||
<Text color={Colors.AccentYellow}>{t('Answering...')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{t('Press Escape, Ctrl+C, or Ctrl+D to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<MarkdownDisplay
|
||||
text={normalizeCodeFences(btw.answer)}
|
||||
isPending={false}
|
||||
contentWidth={contentWidth}
|
||||
/>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{t('Press Space, Enter, or Escape to dismiss')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{btw.isPending ? (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color={Colors.AccentYellow}>{'+ '}</Text>
|
||||
<Text color={Colors.AccentYellow}>{t('Answering...')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t('Press Escape to cancel')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text wrap="wrap">{btw.answer}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{t('Press Space, Enter, or Escape to dismiss')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export const BtwMessage = React.memo(BtwMessageInternal);
|
||||
|
|
|
|||
|
|
@ -67,12 +67,18 @@ export const DefaultAppLayout: React.FC = () => {
|
|||
addItem={uiState.historyManager.addItem}
|
||||
/>
|
||||
</Box>
|
||||
) : uiState.btwItem ? (
|
||||
<Box marginX={2} width={terminalWidth - 4}>
|
||||
<BtwMessage btw={uiState.btwItem.btw} />
|
||||
</Box>
|
||||
) : (
|
||||
<Composer />
|
||||
<>
|
||||
{uiState.btwItem && (
|
||||
<Box marginX={2} width={uiState.mainAreaWidth}>
|
||||
<BtwMessage
|
||||
btw={uiState.btwItem.btw}
|
||||
containerWidth={uiState.mainAreaWidth}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Composer />
|
||||
</>
|
||||
)}
|
||||
<ExitWarning />
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -33,12 +33,18 @@ export const ScreenReaderAppLayout: React.FC = () => {
|
|||
addItem={uiState.historyManager.addItem}
|
||||
/>
|
||||
</Box>
|
||||
) : uiState.btwItem ? (
|
||||
<Box marginX={2} width={uiState.terminalWidth - 4}>
|
||||
<BtwMessage btw={uiState.btwItem.btw} />
|
||||
</Box>
|
||||
) : (
|
||||
<Composer />
|
||||
<>
|
||||
{uiState.btwItem && (
|
||||
<Box marginX={2} width={uiState.mainAreaWidth}>
|
||||
<BtwMessage
|
||||
btw={uiState.btwItem.btw}
|
||||
containerWidth={uiState.mainAreaWidth}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Composer />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ExitWarning />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue