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:
Shaojin Wen 2026-04-03 19:17:12 +08:00 committed by GitHub
parent 61bc80fe19
commit e855229453
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 351 additions and 57 deletions

View file

@ -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.',

View file

@ -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.',

View file

@ -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.': 'フックイベントが見つかりません。',

View file

@ -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.',

View file

@ -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.': 'События хуков не найдены.',

View file

@ -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 事件。',

View file

@ -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;

View file

@ -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'));

View file

@ -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}`,
},
],
},

View file

@ -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">

View file

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

View file

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

View file

@ -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>

View file

@ -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 />