From 3818f8acd47890ebff63c865fa72d2d6c3e6597d Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sat, 14 Mar 2026 15:50:41 +0800 Subject: [PATCH 01/16] feat(cli): add /btw slash command for ephemeral side questions Allow users to ask quick "by the way" questions that use the current conversation context but don't pollute the main conversation history. Closes #2370 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/ui/commands/btwCommand.ts | 138 ++++++++++++++++++ .../src/ui/components/HistoryItemDisplay.tsx | 2 + .../src/ui/components/messages/BtwMessage.tsx | 44 ++++++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 1 + packages/cli/src/ui/types.ts | 15 +- 6 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/ui/commands/btwCommand.ts create mode 100644 packages/cli/src/ui/components/messages/BtwMessage.tsx diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 08ee98eb2..4f198fb0f 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -11,6 +11,7 @@ import { aboutCommand } from '../ui/commands/aboutCommand.js'; import { agentsCommand } from '../ui/commands/agentsCommand.js'; import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; +import { btwCommand } from '../ui/commands/btwCommand.js'; import { bugCommand } from '../ui/commands/bugCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js'; @@ -63,6 +64,7 @@ export class BuiltinCommandLoader implements ICommandLoader { agentsCommand, approvalModeCommand, authCommand, + btwCommand, bugCommand, clearCommand, compressCommand, diff --git a/packages/cli/src/ui/commands/btwCommand.ts b/packages/cli/src/ui/commands/btwCommand.ts new file mode 100644 index 000000000..987a014b5 --- /dev/null +++ b/packages/cli/src/ui/commands/btwCommand.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + CommandContext, + SlashCommand, + SlashCommandActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { MessageType } from '../types.js'; +import type { HistoryItemBtw } from '../types.js'; +import { t } from '../../i18n/index.js'; + +export const btwCommand: SlashCommand = { + name: 'btw', + get description() { + return t( + 'Ask a quick side question without affecting the main conversation', + ); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const question = args.trim(); + + if (!question) { + return { + type: 'message', + messageType: 'error', + content: t('Please provide a question. Usage: /btw '), + }; + } + + const { config } = context.services; + const { ui } = context; + const abortSignal = context.abortSignal; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const geminiClient = config.getGeminiClient(); + if (!geminiClient) { + return { + type: 'message', + messageType: 'error', + content: t('No chat client available.'), + }; + } + + // Show pending state + const pendingItem: HistoryItemBtw = { + type: MessageType.BTW, + btw: { + question, + answer: '', + isPending: true, + }, + }; + ui.setPendingItem(pendingItem); + + try { + // Get current conversation history + const history = geminiClient.getHistory(); + + // Make an ephemeral generateContent call with the conversation context + // but WITHOUT tools — the btw response is purely based on existing context + const response = await geminiClient.generateContent( + [ + ...history, + { + 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}`, + }, + ], + }, + ], + {}, + abortSignal ?? new AbortController().signal, + config.getModel(), + ); + + if (abortSignal?.aborted) { + ui.setPendingItem(null); + return; + } + + // Extract the response text + const parts = response.candidates?.[0]?.content?.parts; + const answer = + parts + ?.map((part) => part.text) + .filter((text): text is string => typeof text === 'string') + .join('') || t('No response received.'); + + // Clear pending and show the completed btw item + ui.setPendingItem(null); + ui.addItem( + { + type: MessageType.BTW, + btw: { + question, + answer, + isPending: false, + }, + } as HistoryItemBtw, + Date.now(), + ); + } catch (error) { + if (abortSignal?.aborted) { + ui.setPendingItem(null); + return; + } + + ui.setPendingItem(null); + ui.addItem( + { + type: MessageType.ERROR, + text: t('Failed to answer btw question: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }), + }, + Date.now(), + ); + } + }, +}; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index a82847cc8..966a4b340 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -39,6 +39,7 @@ import { SkillsList } from './views/SkillsList.js'; import { ToolsList } from './views/ToolsList.js'; import { McpStatus } from './views/McpStatus.js'; import { InsightProgressMessage } from './messages/InsightProgressMessage.js'; +import { BtwMessage } from './messages/BtwMessage.js'; interface HistoryItemDisplayProps { item: HistoryItem; @@ -194,6 +195,7 @@ const HistoryItemDisplayComponent: React.FC = ({ {itemForDisplay.type === 'insight_progress' && ( )} + {itemForDisplay.type === 'btw' && } ); }; diff --git a/packages/cli/src/ui/components/messages/BtwMessage.tsx b/packages/cli/src/ui/components/messages/BtwMessage.tsx new file mode 100644 index 000000000..b1c2196f5 --- /dev/null +++ b/packages/cli/src/ui/components/messages/BtwMessage.tsx @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import type { BtwProps } from '../../types.js'; +import Spinner from 'ink-spinner'; +import { Colors } from '../../colors.js'; + +export interface BtwDisplayProps { + btw: BtwProps; +} + +/** + * BtwMessage renders the /btw (by the way) sidebar response. + * Shows an ephemeral question and answer that doesn't affect the main conversation. + */ +export const BtwMessage: React.FC = ({ btw }) => ( + + + + {'btw> '} + + {btw.question} + + + {btw.isPending ? ( + + + + + Thinking... + + ) : ( + + {btw.answer} + + )} + + + ); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 82cd52060..b22e35909 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -62,6 +62,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([ 'reset', 'new', 'resume', + 'btw', ]); interface SlashCommandProcessorActions { diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index d2483f371..1df5563c9 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -262,6 +262,17 @@ export type HistoryItemInsightProgress = HistoryItemBase & { progress: InsightProgressProps; }; +export interface BtwProps { + question: string; + answer: string; + isPending: boolean; +} + +export type HistoryItemBtw = HistoryItemBase & { + type: 'btw'; + btw: BtwProps; +}; + // Using Omit seems to have some issues with typescript's // type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that // 'tools' in historyItem. @@ -291,7 +302,8 @@ export type HistoryItemWithoutId = | HistoryItemToolsList | HistoryItemSkillsList | HistoryItemMcpStatus - | HistoryItemInsightProgress; + | HistoryItemInsightProgress + | HistoryItemBtw; export type HistoryItem = HistoryItemWithoutId & { id: number }; @@ -315,6 +327,7 @@ export enum MessageType { SKILLS_LIST = 'skills_list', MCP_STATUS = 'mcp_status', INSIGHT_PROGRESS = 'insight_progress', + BTW = 'btw', } export interface InsightProgressProps { From 8dc34c385d9dbdaa2d6b90d4115056e8416d2672 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sat, 14 Mar 2026 15:53:14 +0800 Subject: [PATCH 02/16] fix(cli): address review issues in /btw command - Add pendingItem conflict guard to prevent overwriting other operations - Use finally block to ensure setPendingItem(null) is always called - Wrap "Thinking..." with t() for i18n support - Remove unnecessary type assertion, use typed variable instead Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/commands/btwCommand.ts | 37 ++++++++------- .../src/ui/components/messages/BtwMessage.tsx | 45 ++++++++++--------- 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/ui/commands/btwCommand.ts b/packages/cli/src/ui/commands/btwCommand.ts index 987a014b5..dcb1d9433 100644 --- a/packages/cli/src/ui/commands/btwCommand.ts +++ b/packages/cli/src/ui/commands/btwCommand.ts @@ -57,6 +57,17 @@ export const btwCommand: SlashCommand = { }; } + // Guard against concurrent pending operations + if (ui.pendingItem) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Another operation is in progress. Please wait for it to complete.', + ), + }; + } + // Show pending state const pendingItem: HistoryItemBtw = { type: MessageType.BTW, @@ -92,7 +103,6 @@ export const btwCommand: SlashCommand = { ); if (abortSignal?.aborted) { - ui.setPendingItem(null); return; } @@ -105,25 +115,20 @@ export const btwCommand: SlashCommand = { .join('') || t('No response received.'); // Clear pending and show the completed btw item - ui.setPendingItem(null); - ui.addItem( - { - type: MessageType.BTW, - btw: { - question, - answer, - isPending: false, - }, - } as HistoryItemBtw, - Date.now(), - ); + const completedItem: HistoryItemBtw = { + type: MessageType.BTW, + btw: { + question, + answer, + isPending: false, + }, + }; + ui.addItem(completedItem, Date.now()); } catch (error) { if (abortSignal?.aborted) { - ui.setPendingItem(null); return; } - ui.setPendingItem(null); ui.addItem( { type: MessageType.ERROR, @@ -133,6 +138,8 @@ export const btwCommand: SlashCommand = { }, Date.now(), ); + } finally { + ui.setPendingItem(null); } }, }; diff --git a/packages/cli/src/ui/components/messages/BtwMessage.tsx b/packages/cli/src/ui/components/messages/BtwMessage.tsx index b1c2196f5..f3e040eb8 100644 --- a/packages/cli/src/ui/components/messages/BtwMessage.tsx +++ b/packages/cli/src/ui/components/messages/BtwMessage.tsx @@ -9,6 +9,7 @@ import { Box, Text } from 'ink'; import type { BtwProps } from '../../types.js'; import Spinner from 'ink-spinner'; import { Colors } from '../../colors.js'; +import { t } from '../../../i18n/index.js'; export interface BtwDisplayProps { btw: BtwProps; @@ -19,26 +20,26 @@ export interface BtwDisplayProps { * Shows an ephemeral question and answer that doesn't affect the main conversation. */ export const BtwMessage: React.FC = ({ btw }) => ( - - - - {'btw> '} - - {btw.question} - - - {btw.isPending ? ( - - - - - Thinking... - - ) : ( - - {btw.answer} - - )} - + + + + {'btw> '} + + {btw.question} - ); + + {btw.isPending ? ( + + + + + {t('Thinking...')} + + ) : ( + + {btw.answer} + + )} + + +); From 8b90b145b3c9faf185bc8cbdbf7f347b3fa358bf Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sat, 14 Mar 2026 15:56:39 +0800 Subject: [PATCH 03/16] fix(cli): address audit issues in /btw command - Add ACP mode support with stream_messages async generator - Add non-interactive mode support with simple message return - Add wrap="wrap" to Text components for long text handling - Extract askBtw helper to reduce duplication across execution modes Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/commands/btwCommand.ts | 130 +++++++++++++----- .../src/ui/components/messages/BtwMessage.tsx | 8 +- 2 files changed, 101 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/ui/commands/btwCommand.ts b/packages/cli/src/ui/commands/btwCommand.ts index dcb1d9433..fca90305b 100644 --- a/packages/cli/src/ui/commands/btwCommand.ts +++ b/packages/cli/src/ui/commands/btwCommand.ts @@ -14,6 +14,47 @@ import { MessageType } from '../types.js'; import type { HistoryItemBtw } from '../types.js'; import { t } from '../../i18n/index.js'; +/** + * Helper to make the ephemeral generateContent call and extract the answer. + */ +async function askBtw( + config: NonNullable, + question: string, + abortSignal: AbortSignal, +): Promise { + const geminiClient = config.getGeminiClient(); + if (!geminiClient) { + throw new Error(t('No chat client available.')); + } + + const history = geminiClient.getHistory(); + + const response = await geminiClient.generateContent( + [ + ...history, + { + 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}`, + }, + ], + }, + ], + {}, + abortSignal, + config.getModel(), + ); + + const parts = response.candidates?.[0]?.content?.parts; + return ( + parts + ?.map((part) => part.text) + .filter((text): text is string => typeof text === 'string') + .join('') || t('No response received.') + ); +} + export const btwCommand: SlashCommand = { name: 'btw', get description() { @@ -27,6 +68,8 @@ export const btwCommand: SlashCommand = { args: string, ): Promise => { const question = args.trim(); + const executionMode = context.executionMode ?? 'interactive'; + const abortSignal = context.abortSignal ?? new AbortController().signal; if (!question) { return { @@ -38,7 +81,6 @@ export const btwCommand: SlashCommand = { const { config } = context.services; const { ui } = context; - const abortSignal = context.abortSignal; if (!config) { return { @@ -57,7 +99,55 @@ export const btwCommand: SlashCommand = { }; } - // Guard against concurrent pending operations + // ACP mode: return a stream_messages async generator + if (executionMode === 'acp') { + const messages = async function* () { + try { + yield { + messageType: 'info' as const, + content: t('Thinking...'), + }; + + const answer = await askBtw(config, question, abortSignal); + + yield { + messageType: 'info' as const, + content: `btw> ${question}\n${answer}`, + }; + } catch (error) { + yield { + messageType: 'error' as const, + content: t('Failed to answer btw question: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }), + }; + } + }; + + return { type: 'stream_messages', messages: messages() }; + } + + // Non-interactive mode: return a simple message result + if (executionMode === 'non_interactive') { + try { + const answer = await askBtw(config, question, abortSignal); + return { + type: 'message', + messageType: 'info', + content: `btw> ${question}\n${answer}`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: t('Failed to answer btw question: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }), + }; + } + } + + // Interactive mode: use pending item for spinner, then add to UI history if (ui.pendingItem) { return { type: 'message', @@ -68,7 +158,6 @@ export const btwCommand: SlashCommand = { }; } - // Show pending state const pendingItem: HistoryItemBtw = { type: MessageType.BTW, btw: { @@ -80,41 +169,12 @@ export const btwCommand: SlashCommand = { ui.setPendingItem(pendingItem); try { - // Get current conversation history - const history = geminiClient.getHistory(); + const answer = await askBtw(config, question, abortSignal); - // Make an ephemeral generateContent call with the conversation context - // but WITHOUT tools — the btw response is purely based on existing context - const response = await geminiClient.generateContent( - [ - ...history, - { - 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}`, - }, - ], - }, - ], - {}, - abortSignal ?? new AbortController().signal, - config.getModel(), - ); - - if (abortSignal?.aborted) { + if (abortSignal.aborted) { return; } - // Extract the response text - const parts = response.candidates?.[0]?.content?.parts; - const answer = - parts - ?.map((part) => part.text) - .filter((text): text is string => typeof text === 'string') - .join('') || t('No response received.'); - - // Clear pending and show the completed btw item const completedItem: HistoryItemBtw = { type: MessageType.BTW, btw: { @@ -125,7 +185,7 @@ export const btwCommand: SlashCommand = { }; ui.addItem(completedItem, Date.now()); } catch (error) { - if (abortSignal?.aborted) { + if (abortSignal.aborted) { return; } diff --git a/packages/cli/src/ui/components/messages/BtwMessage.tsx b/packages/cli/src/ui/components/messages/BtwMessage.tsx index f3e040eb8..e71471bdf 100644 --- a/packages/cli/src/ui/components/messages/BtwMessage.tsx +++ b/packages/cli/src/ui/components/messages/BtwMessage.tsx @@ -25,7 +25,9 @@ export const BtwMessage: React.FC = ({ btw }) => ( {'btw> '} - {btw.question} + + {btw.question} + {btw.isPending ? ( @@ -37,7 +39,9 @@ export const BtwMessage: React.FC = ({ btw }) => ( ) : ( - {btw.answer} + + {btw.answer} + )} From fda065314f80f345ed752a24a7d81aeb98d1fb83 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sat, 14 Mar 2026 16:04:10 +0800 Subject: [PATCH 04/16] refactor(cli): simplify /btw by passing geminiClient directly to askBtw Remove redundant null check by passing the already-validated client and model to the helper function instead of re-extracting them. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/commands/btwCommand.ts | 31 ++++++++++------------ 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/ui/commands/btwCommand.ts b/packages/cli/src/ui/commands/btwCommand.ts index fca90305b..e6dbcb5a5 100644 --- a/packages/cli/src/ui/commands/btwCommand.ts +++ b/packages/cli/src/ui/commands/btwCommand.ts @@ -13,20 +13,18 @@ import { CommandKind } from './types.js'; 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'; /** * Helper to make the ephemeral generateContent call and extract the answer. + * Uses a snapshot of the current conversation history as context. */ async function askBtw( - config: NonNullable, + geminiClient: GeminiClient, + model: string, question: string, abortSignal: AbortSignal, ): Promise { - const geminiClient = config.getGeminiClient(); - if (!geminiClient) { - throw new Error(t('No chat client available.')); - } - const history = geminiClient.getHistory(); const response = await geminiClient.generateContent( @@ -43,7 +41,7 @@ async function askBtw( ], {}, abortSignal, - config.getModel(), + model, ); const parts = response.candidates?.[0]?.content?.parts; @@ -91,13 +89,7 @@ export const btwCommand: SlashCommand = { } const geminiClient = config.getGeminiClient(); - if (!geminiClient) { - return { - type: 'message', - messageType: 'error', - content: t('No chat client available.'), - }; - } + const model = config.getModel(); // ACP mode: return a stream_messages async generator if (executionMode === 'acp') { @@ -108,7 +100,12 @@ export const btwCommand: SlashCommand = { content: t('Thinking...'), }; - const answer = await askBtw(config, question, abortSignal); + const answer = await askBtw( + geminiClient, + model, + question, + abortSignal, + ); yield { messageType: 'info' as const, @@ -130,7 +127,7 @@ export const btwCommand: SlashCommand = { // Non-interactive mode: return a simple message result if (executionMode === 'non_interactive') { try { - const answer = await askBtw(config, question, abortSignal); + const answer = await askBtw(geminiClient, model, question, abortSignal); return { type: 'message', messageType: 'info', @@ -169,7 +166,7 @@ export const btwCommand: SlashCommand = { ui.setPendingItem(pendingItem); try { - const answer = await askBtw(config, question, abortSignal); + const answer = await askBtw(geminiClient, model, question, abortSignal); if (abortSignal.aborted) { return; From d285c4409a623e700170f6338e4a7e1691500aa8 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sat, 14 Mar 2026 16:20:21 +0800 Subject: [PATCH 05/16] fix(cli): extract duplicate error formatting and add tests for /btw command Extract repeated error formatting into formatBtwError helper, remove no-op marginTop={0}, and add comprehensive test coverage for all three execution modes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/ui/commands/btwCommand.test.ts | 376 ++++++++++++++++++ packages/cli/src/ui/commands/btwCommand.ts | 18 +- .../src/ui/components/messages/BtwMessage.tsx | 2 +- 3 files changed, 386 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/ui/commands/btwCommand.test.ts diff --git a/packages/cli/src/ui/commands/btwCommand.test.ts b/packages/cli/src/ui/commands/btwCommand.test.ts new file mode 100644 index 000000000..2db053dd8 --- /dev/null +++ b/packages/cli/src/ui/commands/btwCommand.test.ts @@ -0,0 +1,376 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { btwCommand } from './btwCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { CommandKind } from './types.js'; +import { MessageType } from '../types.js'; + +vi.mock('../../i18n/index.js', () => ({ + t: (key: string, params?: Record) => { + if (params) { + return Object.entries(params).reduce( + (str, [k, v]) => str.replace(`{{${k}}}`, v), + key, + ); + } + return key; + }, +})); + +describe('btwCommand', () => { + let mockContext: CommandContext; + let mockGenerateContent: ReturnType; + let mockGetHistory: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockGenerateContent = vi.fn(); + mockGetHistory = vi.fn().mockReturnValue([]); + + mockContext = createMockCommandContext({ + services: { + config: { + getGeminiClient: () => ({ + getHistory: mockGetHistory, + generateContent: mockGenerateContent, + }), + getModel: () => 'test-model', + }, + }, + }); + }); + + it('should have correct metadata', () => { + expect(btwCommand.name).toBe('btw'); + expect(btwCommand.kind).toBe(CommandKind.BUILT_IN); + expect(btwCommand.description).toBeTruthy(); + }); + + it('should return error when no question is provided', async () => { + const result = await btwCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Please provide a question. Usage: /btw ', + }); + }); + + it('should return error when only whitespace is provided', async () => { + const result = await btwCommand.action!(mockContext, ' '); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Please provide a question. Usage: /btw ', + }); + }); + + it('should return error when config is not loaded', async () => { + const noConfigContext = createMockCommandContext({ + services: { config: null }, + }); + + const result = await btwCommand.action!(noConfigContext, 'test question'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }); + }); + + describe('interactive mode', () => { + it('should set pending item and add completed item on success', async () => { + mockGenerateContent.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'The answer is 42.' }], + }, + }, + ], + }); + + await btwCommand.action!(mockContext, 'what is the meaning of life?'); + + expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ + type: MessageType.BTW, + btw: { + question: 'what is the meaning of life?', + answer: '', + isPending: true, + }, + }); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.BTW, + btw: { + question: 'what is the meaning of life?', + answer: 'The answer is 42.', + isPending: false, + }, + }, + expect.any(Number), + ); + + expect(mockContext.ui.setPendingItem).toHaveBeenLastCalledWith(null); + }); + + it('should pass conversation history to generateContent', async () => { + const history = [ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Hi!' }] }, + ]; + mockGetHistory.mockReturnValue(history); + mockGenerateContent.mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'answer' }] } }], + }); + + await btwCommand.action!(mockContext, 'my question'); + + expect(mockGenerateContent).toHaveBeenCalledWith( + [ + ...history, + { + role: 'user', + parts: [ + { + text: expect.stringContaining('my question'), + }, + ], + }, + ], + {}, + expect.any(AbortSignal), + 'test-model', + ); + }); + + it('should add error item on failure', async () => { + mockGenerateContent.mockRejectedValue(new Error('API error')); + + await btwCommand.action!(mockContext, 'test question'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'Failed to answer btw question: API error', + }, + expect.any(Number), + ); + + expect(mockContext.ui.setPendingItem).toHaveBeenLastCalledWith(null); + }); + + it('should handle non-Error exceptions', async () => { + mockGenerateContent.mockRejectedValue('string error'); + + await btwCommand.action!(mockContext, 'test question'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'Failed to answer btw question: string error', + }, + expect.any(Number), + ); + }); + + it('should return error when another operation is pending', async () => { + const busyContext = createMockCommandContext({ + services: { + config: { + getGeminiClient: () => ({ + getHistory: mockGetHistory, + generateContent: mockGenerateContent, + }), + getModel: () => 'test-model', + }, + }, + ui: { + pendingItem: { type: 'info' }, + }, + }); + + const result = await btwCommand.action!(busyContext, 'test question'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + 'Another operation is in progress. Please wait for it to complete.', + }); + }); + + it('should not add item when abort signal is aborted', async () => { + const abortController = new AbortController(); + abortController.abort(); + + const abortContext = createMockCommandContext({ + abortSignal: abortController.signal, + services: { + config: { + getGeminiClient: () => ({ + getHistory: mockGetHistory, + generateContent: mockGenerateContent, + }), + getModel: () => 'test-model', + }, + }, + }); + + mockGenerateContent.mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'answer' }] } }], + }); + + await btwCommand.action!(abortContext, 'test question'); + + expect(abortContext.ui.addItem).not.toHaveBeenCalled(); + expect(abortContext.ui.setPendingItem).toHaveBeenLastCalledWith(null); + }); + + it('should return fallback text when response has no parts', async () => { + mockGenerateContent.mockResolvedValue({ + candidates: [{ content: { parts: [] } }], + }); + + await btwCommand.action!(mockContext, 'test question'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.BTW, + btw: { + question: 'test question', + answer: 'No response received.', + isPending: false, + }, + }, + expect.any(Number), + ); + }); + }); + + describe('non-interactive mode', () => { + let nonInteractiveContext: CommandContext; + + beforeEach(() => { + nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + services: { + config: { + getGeminiClient: () => ({ + getHistory: mockGetHistory, + generateContent: mockGenerateContent, + }), + getModel: () => 'test-model', + }, + }, + }); + }); + + it('should return info message on success', async () => { + mockGenerateContent.mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'the answer' }] } }], + }); + + const result = await btwCommand.action!( + nonInteractiveContext, + 'my question', + ); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'btw> my question\nthe answer', + }); + }); + + it('should return error message on failure', async () => { + mockGenerateContent.mockRejectedValue(new Error('network error')); + + const result = await btwCommand.action!( + nonInteractiveContext, + 'my question', + ); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to answer btw question: network error', + }); + }); + }); + + describe('acp mode', () => { + let acpContext: CommandContext; + + beforeEach(() => { + acpContext = createMockCommandContext({ + executionMode: 'acp', + services: { + config: { + getGeminiClient: () => ({ + getHistory: mockGetHistory, + generateContent: mockGenerateContent, + }), + getModel: () => 'test-model', + }, + }, + }); + }); + + it('should return stream_messages generator on success', async () => { + mockGenerateContent.mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'streamed answer' }] } }], + }); + + const result = (await btwCommand.action!(acpContext, 'my question')) as { + type: string; + messages: AsyncGenerator; + }; + + expect(result.type).toBe('stream_messages'); + + const messages = []; + for await (const msg of result.messages) { + messages.push(msg); + } + + expect(messages).toEqual([ + { messageType: 'info', content: 'Thinking...' }, + { messageType: 'info', content: 'btw> my question\nstreamed answer' }, + ]); + }); + + it('should yield error message on failure', async () => { + mockGenerateContent.mockRejectedValue(new Error('api failure')); + + const result = (await btwCommand.action!(acpContext, 'my question')) as { + type: string; + messages: AsyncGenerator; + }; + + const messages = []; + for await (const msg of result.messages) { + messages.push(msg); + } + + expect(messages).toEqual([ + { messageType: 'info', content: 'Thinking...' }, + { + messageType: 'error', + content: 'Failed to answer btw question: api failure', + }, + ]); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/btwCommand.ts b/packages/cli/src/ui/commands/btwCommand.ts index e6dbcb5a5..541e03021 100644 --- a/packages/cli/src/ui/commands/btwCommand.ts +++ b/packages/cli/src/ui/commands/btwCommand.ts @@ -15,6 +15,12 @@ import type { HistoryItemBtw } from '../types.js'; import { t } from '../../i18n/index.js'; import type { GeminiClient } from '@qwen-code/qwen-code-core'; +function formatBtwError(error: unknown): string { + return t('Failed to answer btw question: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }); +} + /** * Helper to make the ephemeral generateContent call and extract the answer. * Uses a snapshot of the current conversation history as context. @@ -114,9 +120,7 @@ export const btwCommand: SlashCommand = { } catch (error) { yield { messageType: 'error' as const, - content: t('Failed to answer btw question: {{error}}', { - error: error instanceof Error ? error.message : String(error), - }), + content: formatBtwError(error), }; } }; @@ -137,9 +141,7 @@ export const btwCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: t('Failed to answer btw question: {{error}}', { - error: error instanceof Error ? error.message : String(error), - }), + content: formatBtwError(error), }; } } @@ -189,9 +191,7 @@ export const btwCommand: SlashCommand = { ui.addItem( { type: MessageType.ERROR, - text: t('Failed to answer btw question: {{error}}', { - error: error instanceof Error ? error.message : String(error), - }), + text: formatBtwError(error), }, Date.now(), ); diff --git a/packages/cli/src/ui/components/messages/BtwMessage.tsx b/packages/cli/src/ui/components/messages/BtwMessage.tsx index e71471bdf..97d0085e0 100644 --- a/packages/cli/src/ui/components/messages/BtwMessage.tsx +++ b/packages/cli/src/ui/components/messages/BtwMessage.tsx @@ -29,7 +29,7 @@ export const BtwMessage: React.FC = ({ btw }) => ( {btw.question} - + {btw.isPending ? ( From ed9a4edc4053a219e04bea74f92fa439d18f7519 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sat, 14 Mar 2026 16:44:13 +0800 Subject: [PATCH 06/16] fix(cli): add model guard and explicit tools:[] for /btw command Explicitly pass tools:[] to generateContent to prevent tool invocation in side questions. Add model undefined guard for defensive safety. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/ui/commands/btwCommand.test.ts | 24 ++++++++++++++++++- packages/cli/src/ui/commands/btwCommand.ts | 10 +++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/commands/btwCommand.test.ts b/packages/cli/src/ui/commands/btwCommand.test.ts index 2db053dd8..62df6cb1f 100644 --- a/packages/cli/src/ui/commands/btwCommand.test.ts +++ b/packages/cli/src/ui/commands/btwCommand.test.ts @@ -87,6 +87,28 @@ describe('btwCommand', () => { }); }); + it('should return error when model is not configured', async () => { + const noModelContext = createMockCommandContext({ + services: { + config: { + getGeminiClient: () => ({ + getHistory: mockGetHistory, + generateContent: mockGenerateContent, + }), + getModel: () => '', + }, + }, + }); + + const result = await btwCommand.action!(noModelContext, 'test question'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No model configured.', + }); + }); + describe('interactive mode', () => { it('should set pending item and add completed item on success', async () => { mockGenerateContent.mockResolvedValue({ @@ -149,7 +171,7 @@ describe('btwCommand', () => { ], }, ], - {}, + { tools: [] }, expect.any(AbortSignal), 'test-model', ); diff --git a/packages/cli/src/ui/commands/btwCommand.ts b/packages/cli/src/ui/commands/btwCommand.ts index 541e03021..8280465f5 100644 --- a/packages/cli/src/ui/commands/btwCommand.ts +++ b/packages/cli/src/ui/commands/btwCommand.ts @@ -45,7 +45,7 @@ async function askBtw( ], }, ], - {}, + { tools: [] }, abortSignal, model, ); @@ -97,6 +97,14 @@ export const btwCommand: SlashCommand = { const geminiClient = config.getGeminiClient(); const model = config.getModel(); + if (!model) { + return { + type: 'message', + messageType: 'error', + content: t('No model configured.'), + }; + } + // ACP mode: return a stream_messages async generator if (executionMode === 'acp') { const messages = async function* () { From a9c2866ca8650acb1cb263e04c2eec2ffbfc44db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E9=93=81?= Date: Sat, 14 Mar 2026 17:09:57 +0800 Subject: [PATCH 07/16] fix(core): define Anthropic stream types locally to fix verbatimModuleSyntax incompatibility The @anthropic-ai/sdk package's type exports are not compatible with TypeScript's verbatimModuleSyntax option when using NodeNext module resolution. Define the stream event types locally to avoid the import error. Co-authored-by: Qwen-Coder --- packages/core/src/utils/anthropicSseParser.ts | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 packages/core/src/utils/anthropicSseParser.ts diff --git a/packages/core/src/utils/anthropicSseParser.ts b/packages/core/src/utils/anthropicSseParser.ts new file mode 100644 index 000000000..4162ce658 --- /dev/null +++ b/packages/core/src/utils/anthropicSseParser.ts @@ -0,0 +1,259 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Robust SSE parser utilities for Anthropic-compatible APIs. + * + * Some Anthropic-compatible providers return malformed SSE data with + * trailing whitespace inside JSON objects, e.g.: + * data: {"type":"message_stop" } + * + * This module provides utilities to handle such cases. + */ + +// Define types locally to avoid SDK import issues with verbatimModuleSyntax +// These match the types from @anthropic-ai/sdk + +export interface AnthropicMessageStartEvent { + type: 'message_start'; + message: { + id: string; + type: 'message'; + role: 'assistant'; + content: unknown[]; + model: string; + stop_reason: string | null; + stop_sequence: string | null; + usage: { + input_tokens: number; + output_tokens?: number; + }; + }; +} + +export interface AnthropicMessageDeltaEvent { + type: 'message_delta'; + delta: { + stop_reason: string | null; + stop_sequence: string | null; + }; + usage: { + output_tokens: number; + }; +} + +export interface AnthropicMessageStopEvent { + type: 'message_stop'; +} + +export interface AnthropicContentBlockStartEvent { + type: 'content_block_start'; + index: number; + content_block: { + type: 'text' | 'tool_use'; + text?: string; + id?: string; + name?: string; + input?: unknown; + }; +} + +export interface AnthropicContentBlockDeltaEvent { + type: 'content_block_delta'; + index: number; + delta: { + type: 'text_delta' | 'input_json_delta'; + text?: string; + partial_json?: string; + }; +} + +export interface AnthropicContentBlockStopEvent { + type: 'content_block_stop'; + index: number; +} + +export type AnthropicStreamEvent = + | AnthropicMessageStartEvent + | AnthropicMessageDeltaEvent + | AnthropicMessageStopEvent + | AnthropicContentBlockStartEvent + | AnthropicContentBlockDeltaEvent + | AnthropicContentBlockStopEvent; + +/** + * Safely parse SSE data string into an AnthropicStreamEvent. + * Handles malformed JSON with extra whitespace inside objects/arrays. + * + * @param data - The raw SSE data string + * @returns Parsed event or null if parsing fails + */ +export function parseAnthropicSseData( + data: string, +): AnthropicStreamEvent | null { + if (!data || typeof data !== 'string') { + return null; + } + + // Trim leading/trailing whitespace first + let normalizedData = data.trim(); + + try { + // Standard JSON.parse handles most cases + return JSON.parse(normalizedData) as AnthropicStreamEvent; + } catch { + // Some providers return malformed JSON with trailing whitespace + // inside the JSON object before the closing brace, e.g.: + // {"type":"message_stop" } + // + // Try to fix by removing whitespace before } and ] + + // Remove trailing whitespace before closing braces + normalizedData = normalizedData.replace(/\s+}/g, '}'); + // Remove trailing whitespace before closing brackets + normalizedData = normalizedData.replace(/\s+]/g, ']'); + + try { + return JSON.parse(normalizedData) as AnthropicStreamEvent; + } catch { + // Failed to parse, return null + return null; + } + } +} + +/** + * Decode SSE text chunk into individual events. + * Handles both HTTP/1.1 and HTTP/2 streaming formats. + * + * @param chunk - Raw SSE text chunk + * @returns Array of parsed events + */ +export function decodeSseChunk(chunk: string): AnthropicStreamEvent[] { + const events: AnthropicStreamEvent[] = []; + const lines = chunk.split('\n'); + + let currentEvent: string | null = null; + let dataLines: string[] = []; + + for (const line of lines) { + // Handle carriage return + const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line; + + if (!normalizedLine) { + // Empty line signals end of event + if (currentEvent && dataLines.length > 0) { + const data = dataLines.join('\n'); + const parsed = parseAnthropicSseData(data); + if (parsed) { + events.push(parsed); + } + } + // Reset for next event + currentEvent = null; + dataLines = []; + continue; + } + + // Skip comment lines + if (normalizedLine.startsWith(':')) { + continue; + } + + // Parse field + const colonIndex = normalizedLine.indexOf(':'); + if (colonIndex === -1) { + continue; + } + + const fieldName = normalizedLine.substring(0, colonIndex); + let fieldValue = normalizedLine.substring(colonIndex + 1); + + // Remove leading space from value (SSE spec) + if (fieldValue.startsWith(' ')) { + fieldValue = fieldValue.substring(1); + } + + if (fieldName === 'event') { + currentEvent = fieldValue; + } else if (fieldName === 'data') { + dataLines.push(fieldValue); + } + } + + // Handle case where stream doesn't end with empty line + if (currentEvent && dataLines.length > 0) { + const data = dataLines.join('\n'); + const parsed = parseAnthropicSseData(data); + if (parsed) { + events.push(parsed); + } + } + + return events; +} + +/** + * Async generator that parses an SSE response stream. + * Yields parsed Anthropic events as they become available. + * + * @param body - The response body as a ReadableStream + * @returns AsyncGenerator yielding parsed events + */ +export async function* parseAnthropicSseStream( + body: ReadableStream, +): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + // Process any remaining buffered data + if (buffer.trim()) { + const events = decodeSseChunk(buffer); + for (const event of events) { + yield event; + } + } + break; + } + + // Decode chunk and add to buffer + buffer += decoder.decode(value, { stream: true }); + + // Find complete events (separated by double newlines) + // Support \n\n, \r\r, and \r\n\r\n patterns + const eventEndPattern = /(\n\n|\r\r|\r\n\r\n)/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = eventEndPattern.exec(buffer)) !== null) { + const eventText = buffer.substring( + lastIndex, + match.index + match[0].length, + ); + lastIndex = match.index + match[0].length; + + const events = decodeSseChunk(eventText); + for (const event of events) { + yield event; + } + } + + // Remove processed data from buffer + if (lastIndex > 0) { + buffer = buffer.substring(lastIndex); + } + } + } finally { + reader.releaseLock(); + } +} From b1b5f72507c33792e2f85888cf21ff8457f82b3d Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sat, 14 Mar 2026 17:24:43 +0800 Subject: [PATCH 08/16] fix(cli): revert tools:[] to empty config for provider compatibility Revert tools:[] to empty config {} to avoid provider compatibility issues. Empty tools array is truthy and gets passed through to API requests, which can cause errors on some providers. Omitting the tools field entirely achieves the same effect (no tool access). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/commands/btwCommand.test.ts | 2 +- packages/cli/src/ui/commands/btwCommand.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/commands/btwCommand.test.ts b/packages/cli/src/ui/commands/btwCommand.test.ts index 62df6cb1f..cc95b94b5 100644 --- a/packages/cli/src/ui/commands/btwCommand.test.ts +++ b/packages/cli/src/ui/commands/btwCommand.test.ts @@ -171,7 +171,7 @@ describe('btwCommand', () => { ], }, ], - { tools: [] }, + {}, expect.any(AbortSignal), 'test-model', ); diff --git a/packages/cli/src/ui/commands/btwCommand.ts b/packages/cli/src/ui/commands/btwCommand.ts index 8280465f5..3019b4e26 100644 --- a/packages/cli/src/ui/commands/btwCommand.ts +++ b/packages/cli/src/ui/commands/btwCommand.ts @@ -45,7 +45,7 @@ async function askBtw( ], }, ], - { tools: [] }, + {}, // No tools — btw questions are text-only abortSignal, model, ); From 1b651d5c4fa7ef5bc019c569a77106310a1b0895 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sat, 14 Mar 2026 17:41:09 +0800 Subject: [PATCH 09/16] refactor(cli): make /btw non-blocking with fire-and-forget API call Run the /btw API call as fire-and-forget in interactive mode so the main conversation is not blocked while waiting for the answer. The action now returns immediately after setting the pending item, and the background promise updates the UI when the answer arrives. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/ui/commands/btwCommand.test.ts | 32 +++++++++++ packages/cli/src/ui/commands/btwCommand.ts | 57 +++++++++---------- 2 files changed, 60 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/ui/commands/btwCommand.test.ts b/packages/cli/src/ui/commands/btwCommand.test.ts index cc95b94b5..a0ee20ec4 100644 --- a/packages/cli/src/ui/commands/btwCommand.test.ts +++ b/packages/cli/src/ui/commands/btwCommand.test.ts @@ -110,6 +110,10 @@ describe('btwCommand', () => { }); describe('interactive mode', () => { + // Helper to flush microtask queue so fire-and-forget promises settle. + const flushPromises = () => + new Promise((resolve) => setTimeout(resolve, 0)); + it('should set pending item and add completed item on success', async () => { mockGenerateContent.mockResolvedValue({ candidates: [ @@ -123,6 +127,7 @@ describe('btwCommand', () => { await btwCommand.action!(mockContext, 'what is the meaning of life?'); + // Action returns immediately; pending item is set synchronously expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ type: MessageType.BTW, btw: { @@ -132,6 +137,9 @@ describe('btwCommand', () => { }, }); + // Wait for background promise to settle + await flushPromises(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.BTW, @@ -158,6 +166,7 @@ describe('btwCommand', () => { }); await btwCommand.action!(mockContext, 'my question'); + await flushPromises(); expect(mockGenerateContent).toHaveBeenCalledWith( [ @@ -181,6 +190,7 @@ describe('btwCommand', () => { mockGenerateContent.mockRejectedValue(new Error('API error')); await btwCommand.action!(mockContext, 'test question'); + await flushPromises(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { @@ -197,6 +207,7 @@ describe('btwCommand', () => { mockGenerateContent.mockRejectedValue('string error'); await btwCommand.action!(mockContext, 'test question'); + await flushPromises(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { @@ -255,6 +266,7 @@ describe('btwCommand', () => { }); await btwCommand.action!(abortContext, 'test question'); + await flushPromises(); expect(abortContext.ui.addItem).not.toHaveBeenCalled(); expect(abortContext.ui.setPendingItem).toHaveBeenLastCalledWith(null); @@ -266,6 +278,7 @@ describe('btwCommand', () => { }); await btwCommand.action!(mockContext, 'test question'); + await flushPromises(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { @@ -279,6 +292,25 @@ describe('btwCommand', () => { expect.any(Number), ); }); + + it('should return void immediately without blocking', async () => { + mockGenerateContent.mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'answer' }] } }], + }); + + const result = await btwCommand.action!(mockContext, 'test question'); + + // Action should return void (not awaiting the API call) + expect(result).toBeUndefined(); + + // addItem not yet called — background promise hasn't settled + expect(mockContext.ui.addItem).not.toHaveBeenCalled(); + + await flushPromises(); + + // Now the background work has completed + expect(mockContext.ui.addItem).toHaveBeenCalled(); + }); }); describe('non-interactive mode', () => { diff --git a/packages/cli/src/ui/commands/btwCommand.ts b/packages/cli/src/ui/commands/btwCommand.ts index 3019b4e26..9350914ce 100644 --- a/packages/cli/src/ui/commands/btwCommand.ts +++ b/packages/cli/src/ui/commands/btwCommand.ts @@ -175,36 +175,35 @@ export const btwCommand: SlashCommand = { }; ui.setPendingItem(pendingItem); - try { - const answer = await askBtw(geminiClient, model, question, abortSignal); + // Fire-and-forget: run the API call in the background so the main + // conversation is not blocked while waiting for the btw answer. + void askBtw(geminiClient, model, question, abortSignal) + .then((answer) => { + if (abortSignal.aborted) return; - if (abortSignal.aborted) { - return; - } + const completedItem: HistoryItemBtw = { + type: MessageType.BTW, + btw: { + question, + answer, + isPending: false, + }, + }; + ui.addItem(completedItem, Date.now()); + }) + .catch((error) => { + if (abortSignal.aborted) return; - const completedItem: HistoryItemBtw = { - type: MessageType.BTW, - btw: { - question, - answer, - isPending: false, - }, - }; - ui.addItem(completedItem, Date.now()); - } catch (error) { - if (abortSignal.aborted) { - return; - } - - ui.addItem( - { - type: MessageType.ERROR, - text: formatBtwError(error), - }, - Date.now(), - ); - } finally { - ui.setPendingItem(null); - } + ui.addItem( + { + type: MessageType.ERROR, + text: formatBtwError(error), + }, + Date.now(), + ); + }) + .finally(() => { + ui.setPendingItem(null); + }); }, }; From cf7204118ff47b0469bc9cfdb04971d14c1d9710 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sat, 14 Mar 2026 17:46:54 +0800 Subject: [PATCH 10/16] fix(core): avoid corrupting JSON strings in SSE whitespace normalization Replace broad \s+} and \s+] regexes with a narrower pattern that only strips whitespace before closing braces/brackets when preceded by a JSON value terminator (", digit, ] or }). Prevents mangling string values like "hello }" which contain whitespace before braces. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/utils/anthropicSseParser.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/utils/anthropicSseParser.ts b/packages/core/src/utils/anthropicSseParser.ts index 4162ce658..f40204028 100644 --- a/packages/core/src/utils/anthropicSseParser.ts +++ b/packages/core/src/utils/anthropicSseParser.ts @@ -111,10 +111,10 @@ export function parseAnthropicSseData( // // Try to fix by removing whitespace before } and ] - // Remove trailing whitespace before closing braces - normalizedData = normalizedData.replace(/\s+}/g, '}'); - // Remove trailing whitespace before closing brackets - normalizedData = normalizedData.replace(/\s+]/g, ']'); + // Remove trailing whitespace before closing braces/brackets, but only + // when preceded by a JSON value terminator (" or digit or ] or }) + // to avoid corrupting whitespace inside string values like "hello }". + normalizedData = normalizedData.replace(/(["\d\]}])\s+([\]}])/g, '$1$2'); try { return JSON.parse(normalizedData) as AnthropicStreamEvent; From 0a43f0ed6dc3606756e4c266721fcb468868271a Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 20 Mar 2026 00:25:36 +0800 Subject: [PATCH 11/16] feat(core): support promptId context and override in generateContent - Use promptIdContext for stateless requests - Add promptIdOverride parameter to generateContent method - Prefer explicit override over context, context over lastPromptId Co-authored-by: Qwen-Coder --- packages/core/src/core/client.test.ts | 50 +++++++++++++++++++++++++++ packages/core/src/core/client.ts | 6 +++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 8121e1464..f374a1d44 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -34,6 +34,7 @@ import { import { getCoreSystemPrompt } from './prompts.js'; import { DEFAULT_QWEN_FLASH_MODEL } from '../config/models.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import { promptIdContext } from '../utils/promptIdContext.js'; import { setSimulate429 } from '../utils/testUtils.js'; import { ideContextStore } from '../ide/ideContext.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; @@ -2362,6 +2363,55 @@ Other open files: ); }); + it('should prefer the current prompt id context for stateless requests', async () => { + const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; + const abortSignal = new AbortController().signal; + + await promptIdContext.run('btw-prompt-id', async () => { + await client.generateContent( + contents, + {}, + abortSignal, + DEFAULT_QWEN_FLASH_MODEL, + ); + }); + + expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + model: DEFAULT_QWEN_FLASH_MODEL, + contents, + }), + 'btw-prompt-id', + ); + }); + + it('should prefer an explicit prompt id override over the current context', async () => { + const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; + const abortSignal = new AbortController().signal; + + await promptIdContext.run('context-prompt-id', async () => { + await ( + client.generateContent as unknown as ( + ...args: unknown[] + ) => Promise + )( + contents, + {}, + abortSignal, + DEFAULT_QWEN_FLASH_MODEL, + 'override-prompt-id', + ); + }); + + expect(mockContentGenerator.generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + model: DEFAULT_QWEN_FLASH_MODEL, + contents, + }), + 'override-prompt-id', + ); + }); + // Note: there is currently no "fallback mode" model routing; the model used // is always the one explicitly requested by the caller. }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 5c7cfb2a8..64822453a 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -67,6 +67,7 @@ import { reportError } from '../utils/errorReporting.js'; import { getErrorMessage } from '../utils/errors.js'; import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js'; import { flatMapTextParts } from '../utils/partUtils.js'; +import { promptIdContext } from '../utils/promptIdContext.js'; import { retryWithBackoff } from '../utils/retry.js'; // Hook types and utilities @@ -683,8 +684,11 @@ export class GeminiClient { generationConfig: GenerateContentConfig, abortSignal: AbortSignal, model: string, + promptIdOverride?: string, ): Promise { let currentAttemptModel: string = model; + const promptId = + promptIdOverride ?? promptIdContext.getStore() ?? this.lastPromptId!; try { const userMemory = this.config.getUserMemory(); @@ -707,7 +711,7 @@ export class GeminiClient { config: requestConfig, contents, }, - this.lastPromptId!, + promptId, ); }; const result = await retryWithBackoff(apiCall, { From d885ef710a5f445be79849a2f0dc00f72c3139dd Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 20 Mar 2026 00:25:46 +0800 Subject: [PATCH 12/16] chore(core): remove unused anthropicSseParser utility - Delete anthropicSseParser.ts as it's no longer needed Co-authored-by: Qwen-Coder --- packages/core/src/utils/anthropicSseParser.ts | 259 ------------------ 1 file changed, 259 deletions(-) delete mode 100644 packages/core/src/utils/anthropicSseParser.ts diff --git a/packages/core/src/utils/anthropicSseParser.ts b/packages/core/src/utils/anthropicSseParser.ts deleted file mode 100644 index f40204028..000000000 --- a/packages/core/src/utils/anthropicSseParser.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Robust SSE parser utilities for Anthropic-compatible APIs. - * - * Some Anthropic-compatible providers return malformed SSE data with - * trailing whitespace inside JSON objects, e.g.: - * data: {"type":"message_stop" } - * - * This module provides utilities to handle such cases. - */ - -// Define types locally to avoid SDK import issues with verbatimModuleSyntax -// These match the types from @anthropic-ai/sdk - -export interface AnthropicMessageStartEvent { - type: 'message_start'; - message: { - id: string; - type: 'message'; - role: 'assistant'; - content: unknown[]; - model: string; - stop_reason: string | null; - stop_sequence: string | null; - usage: { - input_tokens: number; - output_tokens?: number; - }; - }; -} - -export interface AnthropicMessageDeltaEvent { - type: 'message_delta'; - delta: { - stop_reason: string | null; - stop_sequence: string | null; - }; - usage: { - output_tokens: number; - }; -} - -export interface AnthropicMessageStopEvent { - type: 'message_stop'; -} - -export interface AnthropicContentBlockStartEvent { - type: 'content_block_start'; - index: number; - content_block: { - type: 'text' | 'tool_use'; - text?: string; - id?: string; - name?: string; - input?: unknown; - }; -} - -export interface AnthropicContentBlockDeltaEvent { - type: 'content_block_delta'; - index: number; - delta: { - type: 'text_delta' | 'input_json_delta'; - text?: string; - partial_json?: string; - }; -} - -export interface AnthropicContentBlockStopEvent { - type: 'content_block_stop'; - index: number; -} - -export type AnthropicStreamEvent = - | AnthropicMessageStartEvent - | AnthropicMessageDeltaEvent - | AnthropicMessageStopEvent - | AnthropicContentBlockStartEvent - | AnthropicContentBlockDeltaEvent - | AnthropicContentBlockStopEvent; - -/** - * Safely parse SSE data string into an AnthropicStreamEvent. - * Handles malformed JSON with extra whitespace inside objects/arrays. - * - * @param data - The raw SSE data string - * @returns Parsed event or null if parsing fails - */ -export function parseAnthropicSseData( - data: string, -): AnthropicStreamEvent | null { - if (!data || typeof data !== 'string') { - return null; - } - - // Trim leading/trailing whitespace first - let normalizedData = data.trim(); - - try { - // Standard JSON.parse handles most cases - return JSON.parse(normalizedData) as AnthropicStreamEvent; - } catch { - // Some providers return malformed JSON with trailing whitespace - // inside the JSON object before the closing brace, e.g.: - // {"type":"message_stop" } - // - // Try to fix by removing whitespace before } and ] - - // Remove trailing whitespace before closing braces/brackets, but only - // when preceded by a JSON value terminator (" or digit or ] or }) - // to avoid corrupting whitespace inside string values like "hello }". - normalizedData = normalizedData.replace(/(["\d\]}])\s+([\]}])/g, '$1$2'); - - try { - return JSON.parse(normalizedData) as AnthropicStreamEvent; - } catch { - // Failed to parse, return null - return null; - } - } -} - -/** - * Decode SSE text chunk into individual events. - * Handles both HTTP/1.1 and HTTP/2 streaming formats. - * - * @param chunk - Raw SSE text chunk - * @returns Array of parsed events - */ -export function decodeSseChunk(chunk: string): AnthropicStreamEvent[] { - const events: AnthropicStreamEvent[] = []; - const lines = chunk.split('\n'); - - let currentEvent: string | null = null; - let dataLines: string[] = []; - - for (const line of lines) { - // Handle carriage return - const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line; - - if (!normalizedLine) { - // Empty line signals end of event - if (currentEvent && dataLines.length > 0) { - const data = dataLines.join('\n'); - const parsed = parseAnthropicSseData(data); - if (parsed) { - events.push(parsed); - } - } - // Reset for next event - currentEvent = null; - dataLines = []; - continue; - } - - // Skip comment lines - if (normalizedLine.startsWith(':')) { - continue; - } - - // Parse field - const colonIndex = normalizedLine.indexOf(':'); - if (colonIndex === -1) { - continue; - } - - const fieldName = normalizedLine.substring(0, colonIndex); - let fieldValue = normalizedLine.substring(colonIndex + 1); - - // Remove leading space from value (SSE spec) - if (fieldValue.startsWith(' ')) { - fieldValue = fieldValue.substring(1); - } - - if (fieldName === 'event') { - currentEvent = fieldValue; - } else if (fieldName === 'data') { - dataLines.push(fieldValue); - } - } - - // Handle case where stream doesn't end with empty line - if (currentEvent && dataLines.length > 0) { - const data = dataLines.join('\n'); - const parsed = parseAnthropicSseData(data); - if (parsed) { - events.push(parsed); - } - } - - return events; -} - -/** - * Async generator that parses an SSE response stream. - * Yields parsed Anthropic events as they become available. - * - * @param body - The response body as a ReadableStream - * @returns AsyncGenerator yielding parsed events - */ -export async function* parseAnthropicSseStream( - body: ReadableStream, -): AsyncGenerator { - const reader = body.getReader(); - const decoder = new TextDecoder(); - - let buffer = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - - if (done) { - // Process any remaining buffered data - if (buffer.trim()) { - const events = decodeSseChunk(buffer); - for (const event of events) { - yield event; - } - } - break; - } - - // Decode chunk and add to buffer - buffer += decoder.decode(value, { stream: true }); - - // Find complete events (separated by double newlines) - // Support \n\n, \r\r, and \r\n\r\n patterns - const eventEndPattern = /(\n\n|\r\r|\r\n\r\n)/g; - let lastIndex = 0; - let match: RegExpExecArray | null; - - while ((match = eventEndPattern.exec(buffer)) !== null) { - const eventText = buffer.substring( - lastIndex, - match.index + match[0].length, - ); - lastIndex = match.index + match[0].length; - - const events = decodeSseChunk(eventText); - for (const event of events) { - yield event; - } - } - - // Remove processed data from buffer - if (lastIndex > 0) { - buffer = buffer.substring(lastIndex); - } - } - } finally { - reader.releaseLock(); - } -} From 0a1ffd98ebdc167ba0724d1a30e1732f24feef41 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 20 Mar 2026 00:25:51 +0800 Subject: [PATCH 13/16] feat(cli): make /btw command non-blocking with parallel execution - Add btwItem state management independent from pendingItem - Add cancelBtw functionality to abort in-flight BTW API calls - Allow /btw commands to execute concurrently with main responses - Add isBtwCommand utility function - Update BtwMessage UI with cleaner styling (remove spinner) - Add tests for concurrent /btw execution scenarios - Update layouts to render BTW messages in fixed bottom area Co-authored-by: Qwen-Coder --- .../cli/src/nonInteractiveCliCommands.test.ts | 27 ++ packages/cli/src/nonInteractiveCliCommands.ts | 1 + .../cli/src/test-utils/mockCommandContext.ts | 4 + packages/cli/src/ui/AppContainer.test.tsx | 35 +++ packages/cli/src/ui/AppContainer.tsx | 38 ++- .../cli/src/ui/commands/btwCommand.test.ts | 232 ++++++++++-------- packages/cli/src/ui/commands/btwCommand.ts | 56 +++-- packages/cli/src/ui/commands/types.ts | 11 +- .../src/ui/components/messages/BtwMessage.tsx | 46 ++-- .../cli/src/ui/contexts/UIStateContext.tsx | 4 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 23 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 107 +++++++- packages/cli/src/ui/hooks/useGeminiStream.ts | 35 ++- .../cli/src/ui/layouts/DefaultAppLayout.tsx | 7 + .../src/ui/layouts/ScreenReaderAppLayout.tsx | 8 + .../src/ui/noninteractive/nonInteractiveUi.ts | 4 + packages/cli/src/ui/utils/commandUtils.ts | 15 ++ 17 files changed, 497 insertions(+), 156 deletions(-) diff --git a/packages/cli/src/nonInteractiveCliCommands.test.ts b/packages/cli/src/nonInteractiveCliCommands.test.ts index 76b29f3e0..c1c47c678 100644 --- a/packages/cli/src/nonInteractiveCliCommands.test.ts +++ b/packages/cli/src/nonInteractiveCliCommands.test.ts @@ -149,6 +149,33 @@ describe('handleSlashCommand', () => { } }); + it('should execute /btw when using the default allowed list', async () => { + const mockBtwCommand = { + name: 'btw', + description: 'Ask a side question', + kind: CommandKind.BUILT_IN, + action: vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'info', + content: 'btw> question\nanswer', + }), + }; + mockGetCommands.mockReturnValue([mockBtwCommand]); + + const result = await handleSlashCommand( + '/btw question', + abortController, + mockConfig, + mockSettings, + ); + + expect(mockBtwCommand.action).toHaveBeenCalled(); + expect(result.type).toBe('message'); + if (result.type === 'message') { + expect(result.content).toBe('btw> question\nanswer'); + } + }); + it('should execute file commands regardless of allowed list', async () => { const mockFileCommand = { name: 'custom', diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index b089fa6c2..e6344f5d0 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -42,6 +42,7 @@ export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [ 'init', 'summary', 'compress', + 'btw', 'bug', ] as const; diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index fd825b9df..d6a6c3e6d 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -55,6 +55,10 @@ export const createMockCommandContext = ( setDebugMessage: vi.fn(), pendingItem: null, setPendingItem: vi.fn(), + btwItem: null, + setBtwItem: vi.fn(), + cancelBtw: vi.fn(), + btwAbortControllerRef: { current: null }, loadHistory: vi.fn(), toggleVimEnabled: vi.fn(), extensionsUpdateState: new Map(), diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 9e9d4f673..5601fc836 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -419,6 +419,41 @@ describe('AppContainer State Management', () => { ); }).not.toThrow(); }); + + it('submits /btw immediately instead of queueing while responding', () => { + const mockSubmitQuery = vi.fn(); + const mockQueueMessage = vi.fn(); + + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'responding', + submitQuery: mockSubmitQuery, + initError: null, + pendingHistoryItems: [], + thought: null, + cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), + }); + mockedUseMessageQueue.mockReturnValue({ + messageQueue: [], + addMessage: mockQueueMessage, + clearQueue: vi.fn(), + getQueuedMessagesText: vi.fn().mockReturnValue(''), + }); + + render( + , + ); + + capturedUIActions.handleFinalSubmit('/btw quick side question'); + + expect(mockSubmitQuery).toHaveBeenCalledWith('/btw quick side question'); + expect(mockQueueMessage).not.toHaveBeenCalled(); + }); }); describe('Settings Integration', () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c6bfa67c3..e5f83ed4b 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -68,6 +68,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useVim } from './hooks/vim.js'; +import { isBtwCommand } from './utils/commandUtils.js'; import { type LoadedSettings, SettingScope } from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; import { useFocus } from './hooks/useFocus.js'; @@ -550,6 +551,9 @@ export const AppContainer = (props: AppContainerProps) => { handleSlashCommand, slashCommands, pendingHistoryItems: pendingSlashCommandHistoryItems, + btwItem, + setBtwItem, + cancelBtw, commandContext, shellConfirmationRequest, confirmationRequest, @@ -687,9 +691,16 @@ export const AppContainer = (props: AppContainerProps) => { // Callback for handling final submit (must be after addMessage from useMessageQueue) const handleFinalSubmit = useCallback( (submittedValue: string) => { + if ( + streamingState === StreamingState.Responding && + isBtwCommand(submittedValue) + ) { + void submitQuery(submittedValue); + return; + } addMessage(submittedValue); }, - [addMessage], + [addMessage, streamingState, submitQuery], ); // Welcome back functionality (must be after handleFinalSubmit) @@ -1148,7 +1159,12 @@ export const AppContainer = (props: AppContainerProps) => { handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef); return; } else if (keyMatchers[Command.ESCAPE](key)) { - // Escape key handling + // Dismiss or cancel btw side-question on Escape + if (btwItem) { + cancelBtw(); + return; + } + // Skip if shell is focused (to allow shell's own escape handling) if (embeddedShellFocused) { return; @@ -1190,6 +1206,15 @@ export const AppContainer = (props: AppContainerProps) => { return; } + // Dismiss completed btw side-question on Space or Enter, + // but only when the input buffer is empty so we don't swallow user keystrokes. + if (btwItem && !btwItem.btw.isPending && buffer.text.length === 0) { + if (key.name === 'return' || key.sequence === ' ') { + setBtwItem(null); + return; + } + } + let enteringConstrainHeightMode = false; if (!constrainHeight) { enteringConstrainHeightMode = true; @@ -1244,6 +1269,9 @@ export const AppContainer = (props: AppContainerProps) => { handleSlashCommand, activePtyId, embeddedShellFocused, + btwItem, + setBtwItem, + cancelBtw, settings.merged.general?.debugKeystrokeLogging, isAuthenticating, ], @@ -1403,6 +1431,9 @@ export const AppContainer = (props: AppContainerProps) => { staticExtraHeight, dialogsVisible, pendingHistoryItems, + btwItem, + setBtwItem, + cancelBtw, nightly, branchName, sessionStats, @@ -1495,6 +1526,9 @@ export const AppContainer = (props: AppContainerProps) => { staticExtraHeight, dialogsVisible, pendingHistoryItems, + btwItem, + setBtwItem, + cancelBtw, nightly, branchName, sessionStats, diff --git a/packages/cli/src/ui/commands/btwCommand.test.ts b/packages/cli/src/ui/commands/btwCommand.test.ts index a0ee20ec4..99dfa40d3 100644 --- a/packages/cli/src/ui/commands/btwCommand.test.ts +++ b/packages/cli/src/ui/commands/btwCommand.test.ts @@ -27,6 +27,15 @@ describe('btwCommand', () => { let mockContext: CommandContext; let mockGenerateContent: ReturnType; let mockGetHistory: ReturnType; + const createConfig = (overrides: Record = {}) => ({ + getGeminiClient: () => ({ + getHistory: mockGetHistory, + generateContent: mockGenerateContent, + }), + getModel: () => 'test-model', + getSessionId: () => 'test-session-id', + ...overrides, + }); beforeEach(() => { vi.clearAllMocks(); @@ -36,13 +45,7 @@ describe('btwCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getGeminiClient: () => ({ - getHistory: mockGetHistory, - generateContent: mockGenerateContent, - }), - getModel: () => 'test-model', - }, + config: createConfig(), }, }); }); @@ -90,13 +93,9 @@ describe('btwCommand', () => { it('should return error when model is not configured', async () => { const noModelContext = createMockCommandContext({ services: { - config: { - getGeminiClient: () => ({ - getHistory: mockGetHistory, - generateContent: mockGenerateContent, - }), + config: createConfig({ getModel: () => '', - }, + }), }, }); @@ -110,11 +109,10 @@ describe('btwCommand', () => { }); describe('interactive mode', () => { - // Helper to flush microtask queue so fire-and-forget promises settle. const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); - it('should set pending item and add completed item on success', async () => { + it('should set btwItem and update it on success', async () => { mockGenerateContent.mockResolvedValue({ candidates: [ { @@ -127,8 +125,8 @@ describe('btwCommand', () => { await btwCommand.action!(mockContext, 'what is the meaning of life?'); - // Action returns immediately; pending item is set synchronously - expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ + // Action returns immediately; btwItem is set synchronously + expect(mockContext.ui.setBtwItem).toHaveBeenCalledWith({ type: MessageType.BTW, btw: { question: 'what is the meaning of life?', @@ -137,22 +135,23 @@ describe('btwCommand', () => { }, }); - // Wait for background promise to settle + // pendingItem should NOT be used + expect(mockContext.ui.setPendingItem).not.toHaveBeenCalled(); + await flushPromises(); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.BTW, - btw: { - question: 'what is the meaning of life?', - answer: 'The answer is 42.', - isPending: false, - }, + // On success, setBtwItem is called with the completed answer + expect(mockContext.ui.setBtwItem).toHaveBeenCalledWith({ + type: MessageType.BTW, + btw: { + question: 'what is the meaning of life?', + answer: 'The answer is 42.', + isPending: false, }, - expect.any(Number), - ); + }); - expect(mockContext.ui.setPendingItem).toHaveBeenLastCalledWith(null); + // addItem should NOT be called (btw stays in fixed area, not in history) + expect(mockContext.ui.addItem).not.toHaveBeenCalled(); }); it('should pass conversation history to generateContent', async () => { @@ -183,15 +182,20 @@ describe('btwCommand', () => { {}, expect.any(AbortSignal), 'test-model', + expect.stringMatching(/^test-session-id########btw-/), ); }); - it('should add error item on failure', async () => { + it('should add error item on failure and clear btwItem', async () => { mockGenerateContent.mockRejectedValue(new Error('API error')); await btwCommand.action!(mockContext, 'test question'); await flushPromises(); + // btwItem should be cleared on error + expect(mockContext.ui.setBtwItem).toHaveBeenCalledWith(null); + + // Error goes to history expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: MessageType.ERROR, @@ -199,8 +203,6 @@ describe('btwCommand', () => { }, expect.any(Number), ); - - expect(mockContext.ui.setPendingItem).toHaveBeenLastCalledWith(null); }); it('should handle non-Error exceptions', async () => { @@ -218,58 +220,106 @@ describe('btwCommand', () => { ); }); - it('should return error when another operation is pending', async () => { + it('should not block when another pendingItem exists', async () => { const busyContext = createMockCommandContext({ services: { - config: { - getGeminiClient: () => ({ - getHistory: mockGetHistory, - generateContent: mockGenerateContent, - }), - getModel: () => 'test-model', - }, + config: createConfig(), }, ui: { pendingItem: { type: 'info' }, }, }); - const result = await btwCommand.action!(busyContext, 'test question'); - - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: - 'Another operation is in progress. Please wait for it to complete.', - }); - }); - - it('should not add item when abort signal is aborted', async () => { - const abortController = new AbortController(); - abortController.abort(); - - const abortContext = createMockCommandContext({ - abortSignal: abortController.signal, - services: { - config: { - getGeminiClient: () => ({ - getHistory: mockGetHistory, - generateContent: mockGenerateContent, - }), - getModel: () => 'test-model', - }, - }, - }); - mockGenerateContent.mockResolvedValue({ candidates: [{ content: { parts: [{ text: 'answer' }] } }], }); - await btwCommand.action!(abortContext, 'test question'); + // btw should NOT be blocked by pendingItem anymore + const result = await btwCommand.action!(busyContext, 'test question'); + expect(result).toBeUndefined(); + expect(busyContext.ui.setBtwItem).toHaveBeenCalled(); + }); + + it('should not update btwItem when cancelled via btwAbortControllerRef', async () => { + mockGenerateContent.mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + candidates: [ + { content: { parts: [{ text: 'late answer' }] } }, + ], + }), + 50, + ), + ), + ); + + await btwCommand.action!(mockContext, 'test question'); + + // The btw command should have registered its AbortController + expect(mockContext.ui.btwAbortControllerRef.current).toBeInstanceOf( + AbortController, + ); + + // Simulate user pressing ESC: cancel the in-flight btw + mockContext.ui.btwAbortControllerRef.current!.abort(); + await flushPromises(); - expect(abortContext.ui.addItem).not.toHaveBeenCalled(); - expect(abortContext.ui.setPendingItem).toHaveBeenLastCalledWith(null); + // setBtwItem should only have the initial pending call (no completion) + expect(mockContext.ui.setBtwItem).toHaveBeenCalledTimes(1); + expect(mockContext.ui.addItem).not.toHaveBeenCalled(); + }); + + it('should clear btwAbortControllerRef after successful completion', async () => { + mockGenerateContent.mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'answer' }] } }], + }); + + await btwCommand.action!(mockContext, 'test question'); + + // Ref is set during the call + expect(mockContext.ui.btwAbortControllerRef.current).toBeInstanceOf( + AbortController, + ); + + await flushPromises(); + + // After completion, ref should be cleaned up + expect(mockContext.ui.btwAbortControllerRef.current).toBeNull(); + }); + + it('should clear btwAbortControllerRef after error', async () => { + mockGenerateContent.mockRejectedValue(new Error('API error')); + + await btwCommand.action!(mockContext, 'test question'); + + expect(mockContext.ui.btwAbortControllerRef.current).toBeInstanceOf( + AbortController, + ); + + await flushPromises(); + + expect(mockContext.ui.btwAbortControllerRef.current).toBeNull(); + }); + + it('should cancel previous btw when starting a new one', async () => { + mockGenerateContent.mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'answer' }] } }], + }); + + await btwCommand.action!(mockContext, 'first question'); + + // cancelBtw should have been called to clean up any previous btw + expect(mockContext.ui.cancelBtw).toHaveBeenCalledTimes(1); + + // Second btw call + await btwCommand.action!(mockContext, 'second question'); + + // cancelBtw called again for the second invocation + expect(mockContext.ui.cancelBtw).toHaveBeenCalledTimes(2); }); it('should return fallback text when response has no parts', async () => { @@ -280,17 +330,14 @@ describe('btwCommand', () => { await btwCommand.action!(mockContext, 'test question'); await flushPromises(); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.BTW, - btw: { - question: 'test question', - answer: 'No response received.', - isPending: false, - }, + expect(mockContext.ui.setBtwItem).toHaveBeenCalledWith({ + type: MessageType.BTW, + btw: { + question: 'test question', + answer: 'No response received.', + isPending: false, }, - expect.any(Number), - ); + }); }); it('should return void immediately without blocking', async () => { @@ -300,16 +347,15 @@ describe('btwCommand', () => { const result = await btwCommand.action!(mockContext, 'test question'); - // Action should return void (not awaiting the API call) expect(result).toBeUndefined(); - // addItem not yet called — background promise hasn't settled - expect(mockContext.ui.addItem).not.toHaveBeenCalled(); + // Only the pending setBtwItem called so far + expect(mockContext.ui.setBtwItem).toHaveBeenCalledTimes(1); await flushPromises(); - // Now the background work has completed - expect(mockContext.ui.addItem).toHaveBeenCalled(); + // Now the completed setBtwItem has been called + expect(mockContext.ui.setBtwItem).toHaveBeenCalledTimes(2); }); }); @@ -320,13 +366,7 @@ describe('btwCommand', () => { nonInteractiveContext = createMockCommandContext({ executionMode: 'non_interactive', services: { - config: { - getGeminiClient: () => ({ - getHistory: mockGetHistory, - generateContent: mockGenerateContent, - }), - getModel: () => 'test-model', - }, + config: createConfig(), }, }); }); @@ -371,13 +411,7 @@ describe('btwCommand', () => { acpContext = createMockCommandContext({ executionMode: 'acp', services: { - config: { - getGeminiClient: () => ({ - getHistory: mockGetHistory, - generateContent: mockGenerateContent, - }), - getModel: () => 'test-model', - }, + config: createConfig(), }, }); }); diff --git a/packages/cli/src/ui/commands/btwCommand.ts b/packages/cli/src/ui/commands/btwCommand.ts index 9350914ce..7ee5668df 100644 --- a/packages/cli/src/ui/commands/btwCommand.ts +++ b/packages/cli/src/ui/commands/btwCommand.ts @@ -15,6 +15,10 @@ import type { HistoryItemBtw } from '../types.js'; import { t } from '../../i18n/index.js'; import type { GeminiClient } from '@qwen-code/qwen-code-core'; +function makeBtwPromptId(sessionId: string): string { + return `${sessionId}########btw-${Date.now()}`; +} + function formatBtwError(error: unknown): string { return t('Failed to answer btw question: {{error}}', { error: error instanceof Error ? error.message : String(error), @@ -30,6 +34,7 @@ async function askBtw( model: string, question: string, abortSignal: AbortSignal, + promptId: string, ): Promise { const history = geminiClient.getHistory(); @@ -45,9 +50,10 @@ async function askBtw( ], }, ], - {}, // No tools — btw questions are text-only + {}, abortSignal, model, + promptId, ); const parts = response.candidates?.[0]?.content?.parts; @@ -96,6 +102,7 @@ export const btwCommand: SlashCommand = { const geminiClient = config.getGeminiClient(); const model = config.getModel(); + const sessionId = config.getSessionId(); if (!model) { return { @@ -107,6 +114,7 @@ export const btwCommand: SlashCommand = { // ACP mode: return a stream_messages async generator if (executionMode === 'acp') { + const btwPromptId = makeBtwPromptId(sessionId); const messages = async function* () { try { yield { @@ -119,6 +127,7 @@ export const btwCommand: SlashCommand = { model, question, abortSignal, + btwPromptId, ); yield { @@ -139,7 +148,14 @@ export const btwCommand: SlashCommand = { // Non-interactive mode: return a simple message result if (executionMode === 'non_interactive') { try { - const answer = await askBtw(geminiClient, model, question, abortSignal); + const btwPromptId = makeBtwPromptId(sessionId); + const answer = await askBtw( + geminiClient, + model, + question, + abortSignal, + btwPromptId, + ); return { type: 'message', messageType: 'info', @@ -154,16 +170,15 @@ export const btwCommand: SlashCommand = { } } - // Interactive mode: use pending item for spinner, then add to UI history - if (ui.pendingItem) { - return { - type: 'message', - messageType: 'error', - content: t( - 'Another operation is in progress. Please wait for it to complete.', - ), - }; - } + // Interactive mode: use dedicated btwItem state for the fixed bottom area. + // This does NOT occupy pendingItem, so the main conversation is never blocked. + + // Cancel any previous in-flight btw before starting a new one. + ui.cancelBtw(); + + const btwAbortController = new AbortController(); + const btwSignal = btwAbortController.signal; + ui.btwAbortControllerRef.current = btwAbortController; const pendingItem: HistoryItemBtw = { type: MessageType.BTW, @@ -173,14 +188,16 @@ export const btwCommand: SlashCommand = { isPending: true, }, }; - ui.setPendingItem(pendingItem); + ui.setBtwItem(pendingItem); // Fire-and-forget: run the API call in the background so the main // conversation is not blocked while waiting for the btw answer. - void askBtw(geminiClient, model, question, abortSignal) + const btwPromptId = makeBtwPromptId(sessionId); + void askBtw(geminiClient, model, question, btwSignal, btwPromptId) .then((answer) => { - if (abortSignal.aborted) return; + if (btwSignal.aborted) return; + ui.btwAbortControllerRef.current = null; const completedItem: HistoryItemBtw = { type: MessageType.BTW, btw: { @@ -189,11 +206,13 @@ export const btwCommand: SlashCommand = { isPending: false, }, }; - ui.addItem(completedItem, Date.now()); + ui.setBtwItem(completedItem); }) .catch((error) => { - if (abortSignal.aborted) return; + if (btwSignal.aborted) return; + ui.btwAbortControllerRef.current = null; + ui.setBtwItem(null); ui.addItem( { type: MessageType.ERROR, @@ -201,9 +220,6 @@ export const btwCommand: SlashCommand = { }, Date.now(), ); - }) - .finally(() => { - ui.setPendingItem(null); }); }, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 76eda2c07..3fe41647b 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -4,12 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ReactNode } from 'react'; +import type { MutableRefObject, ReactNode } from 'react'; import type { Content, PartListUnion } from '@google/genai'; import type { Config, GitService, Logger } from '@qwen-code/qwen-code-core'; import type { HistoryItemWithoutId, HistoryItem, + HistoryItemBtw, ConfirmationRequest, } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; @@ -66,6 +67,14 @@ export interface CommandContext { * @param item The history item to display as pending, or `null` to clear. */ setPendingItem: (item: HistoryItemWithoutId | null) => void; + /** The current btw side-question item rendered in the fixed bottom area. */ + btwItem: HistoryItemBtw | null; + /** Sets the btw item independently of the main pendingItem. */ + setBtwItem: (item: HistoryItemBtw | null) => void; + /** Cancels a pending btw (aborts the in-flight API call and clears the btw area). */ + cancelBtw: () => void; + /** Ref to the btw AbortController, set by btwCommand so cancelBtw can abort it. */ + btwAbortControllerRef: MutableRefObject; /** * Loads a new set of history items, replacing the current history. * diff --git a/packages/cli/src/ui/components/messages/BtwMessage.tsx b/packages/cli/src/ui/components/messages/BtwMessage.tsx index 97d0085e0..8a7ac9d15 100644 --- a/packages/cli/src/ui/components/messages/BtwMessage.tsx +++ b/packages/cli/src/ui/components/messages/BtwMessage.tsx @@ -7,7 +7,6 @@ import type React from 'react'; import { Box, Text } from 'ink'; import type { BtwProps } from '../../types.js'; -import Spinner from 'ink-spinner'; import { Colors } from '../../colors.js'; import { t } from '../../../i18n/index.js'; @@ -15,35 +14,34 @@ export interface BtwDisplayProps { btw: BtwProps; } -/** - * BtwMessage renders the /btw (by the way) sidebar response. - * Shows an ephemeral question and answer that doesn't affect the main conversation. - */ export const BtwMessage: React.FC = ({ btw }) => ( - + - - {'btw> '} + + {'/btw '} - + {btw.question} - - {btw.isPending ? ( - - - - - {t('Thinking...')} + {btw.isPending ? ( + + {'+ '} + {t('Answering...')} + + ) : ( + + {btw.answer} + + {t('Press Space, Enter, or Escape to dismiss')} - ) : ( - - - {btw.answer} - - - )} - + + )} ); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 0d461e70c..7f2e25ec7 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -7,6 +7,7 @@ import { createContext, useContext } from 'react'; import type { HistoryItem, + HistoryItemBtw, ThoughtSummary, ShellConfirmationRequest, ConfirmationRequest, @@ -101,6 +102,9 @@ export interface UIState { staticExtraHeight: number; dialogsVisible: boolean; pendingHistoryItems: HistoryItemWithoutId[]; + btwItem: HistoryItemBtw | null; + setBtwItem: (item: HistoryItemBtw | null) => void; + cancelBtw: () => void; nightly: boolean; branchName: string | undefined; sessionStats: SessionStatsState; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index b22e35909..bcdeaa34c 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -22,6 +22,7 @@ import { useSessionStats } from '../contexts/SessionContext.js'; import type { Message, HistoryItemWithoutId, + HistoryItemBtw, SlashCommandProcessorResult, HistoryItem, ConfirmationRequest, @@ -137,10 +138,20 @@ export const useSlashCommandProcessor = ( null, ); + const [btwItem, setBtwItem] = useState(null); + const btwAbortControllerRef = useRef(null); + + const cancelBtw = useCallback(() => { + btwAbortControllerRef.current?.abort(); + btwAbortControllerRef.current = null; + setBtwItem(null); + }, []); + // AbortController for cancelling async slash commands via ESC const abortControllerRef = useRef(null); const cancelSlashCommand = useCallback(() => { + cancelBtw(); if (!abortControllerRef.current) { return; } @@ -154,7 +165,7 @@ export const useSlashCommandProcessor = ( ); setPendingItem(null); setIsProcessing(false); - }, [addItem, setIsProcessing]); + }, [addItem, setIsProcessing, cancelBtw]); useKeypress( (key) => { @@ -249,6 +260,10 @@ export const useSlashCommandProcessor = ( setDebugMessage: actions.setDebugMessage, pendingItem, setPendingItem, + btwItem, + setBtwItem, + cancelBtw, + btwAbortControllerRef, toggleVimEnabled, setGeminiMdFileCount, reloadCommands, @@ -277,6 +292,9 @@ export const useSlashCommandProcessor = ( actions, pendingItem, setPendingItem, + btwItem, + setBtwItem, + cancelBtw, toggleVimEnabled, sessionShellAllowlist, setGeminiMdFileCount, @@ -710,6 +728,9 @@ export const useSlashCommandProcessor = ( handleSlashCommand, slashCommands: commands, pendingHistoryItems, + btwItem, + setBtwItem, + cancelBtw, commandContext, shellConfirmationRequest, confirmationRequest, diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index e6696ae6b..49af6521e 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -832,7 +832,7 @@ describe('useGeminiStream', () => { // Wait for the first part of the response await waitFor(() => { - expect(result.current.streamingState).toBe(StreamingState.Responding); + expect(mockSendMessageStream).toHaveBeenCalledTimes(1); }); // Call cancelOngoingRequest directly @@ -981,7 +981,7 @@ describe('useGeminiStream', () => { }); await waitFor(() => { - expect(result.current.streamingState).toBe(StreamingState.Responding); + expect(mockSendMessageStream).toHaveBeenCalledTimes(1); }); // Cancel the request @@ -2707,6 +2707,109 @@ describe('useGeminiStream', () => { }); describe('Concurrent Execution Prevention', () => { + it('should allow /btw slash commands while a main response is in progress', async () => { + let resolveFirstCall!: () => void; + + const firstCallPromise = new Promise((resolve) => { + resolveFirstCall = resolve; + }); + + const firstStream = (async function* () { + yield { + type: ServerGeminiEventType.Content, + value: 'First call content', + }; + await firstCallPromise; + })(); + + mockSendMessageStream.mockImplementation(() => firstStream); + mockHandleSlashCommand.mockImplementation(async (command) => { + if (command === '/btw quick side question') { + return { type: 'handled' }; + } + return false; + }); + + const { result } = renderTestHook(); + + let mainRequest!: Promise; + await act(async () => { + mainRequest = result.current.submitQuery('First query'); + }); + + try { + await waitFor(() => { + expect(mockSendMessageStream).toHaveBeenCalledTimes(1); + expect(result.current.streamingState).toBe(StreamingState.Responding); + }); + + await act(async () => { + await result.current.submitQuery('/btw quick side question'); + }); + + expect(mockHandleSlashCommand).toHaveBeenCalledWith( + '/btw quick side question', + ); + expect(mockSendMessageStream).toHaveBeenCalledTimes(1); + } finally { + resolveFirstCall(); + await mainRequest; + } + }); + + it('should keep the main request cancellable after submitting /btw in parallel', async () => { + let resolveFirstCall!: () => void; + let mainAbortSignal: AbortSignal | undefined; + + const firstCallPromise = new Promise((resolve) => { + resolveFirstCall = resolve; + }); + + mockSendMessageStream.mockImplementation((_query, signal) => { + mainAbortSignal = signal; + return (async function* () { + yield { + type: ServerGeminiEventType.Content, + value: 'First call content', + }; + await firstCallPromise; + })(); + }); + mockHandleSlashCommand.mockImplementation(async (command) => { + if (command === '/btw quick side question') { + return { type: 'handled' }; + } + return false; + }); + + const { result } = renderTestHook(); + + let mainRequest!: Promise; + await act(async () => { + mainRequest = result.current.submitQuery('First query'); + }); + + try { + await waitFor(() => { + expect(mainAbortSignal).toBeDefined(); + expect(result.current.streamingState).toBe(StreamingState.Responding); + }); + + await act(async () => { + await result.current.submitQuery('/btw quick side question'); + }); + + act(() => { + result.current.cancelOngoingRequest(); + }); + + expect(mainAbortSignal?.aborted).toBe(true); + } finally { + resolveFirstCall(); + await mainRequest; + } + }); + it('should prevent concurrent submitQuery calls', async () => { let resolveFirstCall!: () => void; let resolveSecondCall!: () => void; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 7614eed00..1d4d736aa 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -46,7 +46,11 @@ import type { SlashCommandProcessorResult, } from '../types.js'; import { StreamingState, MessageType, ToolCallStatus } from '../types.js'; -import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; +import { + isAtCommand, + isBtwCommand, + isSlashCommand, +} from '../utils/commandUtils.js'; import { useShellCommandProcessor } from './shellCommandProcessor.js'; import { handleAtCommand } from './atCommandProcessor.js'; import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; @@ -1085,16 +1089,27 @@ export const useGeminiStream = ( options?: { isContinuation: boolean; skipPreparation?: boolean }, prompt_id?: string, ) => { + const allowConcurrentBtwDuringResponse = + !options?.isContinuation && + streamingState === StreamingState.Responding && + typeof query === 'string' && + isBtwCommand(query); + // Prevent concurrent executions of submitQuery, but allow continuations // which are part of the same logical flow (tool responses) - if (isSubmittingQueryRef.current && !options?.isContinuation) { + if ( + isSubmittingQueryRef.current && + !options?.isContinuation && + !allowConcurrentBtwDuringResponse + ) { return; } if ( (streamingState === StreamingState.Responding || streamingState === StreamingState.WaitingForConfirmation) && - !options?.isContinuation + !options?.isContinuation && + !allowConcurrentBtwDuringResponse ) return; @@ -1104,7 +1119,7 @@ export const useGeminiStream = ( const userMessageTimestamp = Date.now(); // Reset quota error flag when starting a new query (not a continuation) - if (!options?.isContinuation) { + if (!options?.isContinuation && !allowConcurrentBtwDuringResponse) { setModelSwitchedFromQuotaError(false); // Commit any pending retry error to history (without hint) since the // user is starting a new conversation turn. @@ -1118,9 +1133,15 @@ export const useGeminiStream = ( } } - abortControllerRef.current = new AbortController(); - const abortSignal = abortControllerRef.current.signal; - turnCancelledRef.current = false; + const abortController = new AbortController(); + const abortSignal = abortController.signal; + + // Keep the main stream's cancellation state intact while /btw is handled + // in parallel. The side-question can use its own local abort signal. + if (!allowConcurrentBtwDuringResponse) { + abortControllerRef.current = abortController; + turnCancelledRef.current = false; + } if (!prompt_id) { prompt_id = config.getSessionId() + '########' + getPromptCount(); diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 93ad311c6..1dd81ecb2 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -10,6 +10,7 @@ import { MainContent } from '../components/MainContent.js'; import { DialogManager } from '../components/DialogManager.js'; import { Composer } from '../components/Composer.js'; import { ExitWarning } from '../components/ExitWarning.js'; +import { BtwMessage } from '../components/messages/BtwMessage.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; @@ -21,6 +22,12 @@ export const DefaultAppLayout: React.FC = () => { + {uiState.btwItem && ( + + + + )} + {uiState.dialogsVisible ? ( diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx index b4967a5f4..633f631ee 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx @@ -12,6 +12,7 @@ import { DialogManager } from '../components/DialogManager.js'; import { Composer } from '../components/Composer.js'; import { Footer } from '../components/Footer.js'; import { ExitWarning } from '../components/ExitWarning.js'; +import { BtwMessage } from '../components/messages/BtwMessage.js'; import { useUIState } from '../contexts/UIStateContext.js'; export const ScreenReaderAppLayout: React.FC = () => { @@ -24,6 +25,13 @@ export const ScreenReaderAppLayout: React.FC = () => { + + {uiState.btwItem && ( + + + + )} + {uiState.dialogsVisible ? ( {}, pendingItem: null, setPendingItem: (_item) => {}, + btwItem: null, + setBtwItem: (_item) => {}, + cancelBtw: () => {}, + btwAbortControllerRef: { current: null }, toggleVimEnabled: async () => false, setGeminiMdFileCount: (_count) => {}, reloadCommands: () => {}, diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 802107f6b..69038eaad 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -62,6 +62,21 @@ export const isSlashCommand = (query: string): boolean => { return true; }; +/** + * Checks if a query is a /btw side-question invocation. + * Accepts both "/btw" and "?btw" prefixes. + */ +export const isBtwCommand = (query: string): boolean => { + const trimmed = query.trim(); + if (!trimmed) { + return false; + } + + const normalized = trimmed.startsWith('?') ? `/${trimmed.slice(1)}` : trimmed; + + return /^\/btw(?:\s|$)/.test(normalized); +}; + const debugLogger = createDebugLogger('COMMAND_UTILS'); // Copies a string snippet to the clipboard for different platforms From 130d6888b4963a272bb9dedbb1c9f13d0c48c845 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 20 Mar 2026 13:21:25 +0800 Subject: [PATCH 14/16] perf(cli): memoize btw message component --- .../components/messages/BtwMessage.test.tsx | 34 +++++++++++++++++++ .../src/ui/components/messages/BtwMessage.tsx | 6 ++-- 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/BtwMessage.test.tsx diff --git a/packages/cli/src/ui/components/messages/BtwMessage.test.tsx b/packages/cli/src/ui/components/messages/BtwMessage.test.tsx new file mode 100644 index 000000000..da784dc0d --- /dev/null +++ b/packages/cli/src/ui/components/messages/BtwMessage.test.tsx @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { render } from 'ink-testing-library'; +import { BtwMessage } from './BtwMessage.js'; + +describe('BtwMessage', () => { + it('is wrapped in React.memo to avoid unnecessary layout rerenders', () => { + expect((BtwMessage as unknown as { $$typeof?: symbol }).$$typeof).toBe( + Symbol.for('react.memo'), + ); + }); + + it('renders the side question and answer', () => { + const { lastFrame } = render( + , + ); + + const output = lastFrame() ?? ''; + expect(output).toContain('/btw'); + expect(output).toContain('side question'); + expect(output).toContain('side answer'); + }); +}); diff --git a/packages/cli/src/ui/components/messages/BtwMessage.tsx b/packages/cli/src/ui/components/messages/BtwMessage.tsx index 8a7ac9d15..a172d43fa 100644 --- a/packages/cli/src/ui/components/messages/BtwMessage.tsx +++ b/packages/cli/src/ui/components/messages/BtwMessage.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import React from 'react'; import { Box, Text } from 'ink'; import type { BtwProps } from '../../types.js'; import { Colors } from '../../colors.js'; @@ -14,7 +14,7 @@ export interface BtwDisplayProps { btw: BtwProps; } -export const BtwMessage: React.FC = ({ btw }) => ( +const BtwMessageInternal: React.FC = ({ btw }) => ( = ({ btw }) => ( )} ); + +export const BtwMessage = React.memo(BtwMessageInternal); From dff9822f9b61b731643eb1c10ea09d0164b20d6f Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 21 Mar 2026 01:07:02 +0800 Subject: [PATCH 15/16] =?UTF-8?q?fix(cli):=20improve=20/btw=20overlay=20UX?= =?UTF-8?q?=20=E2=80=94=20layout,=20dismiss=20hints,=20and=20history=20cle?= =?UTF-8?q?anup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make /btw overlay mutually exclusive with Composer (replaces input area) - Add dismiss hints: "Press Escape to cancel" (pending) / "Press Space, Enter, or Escape to dismiss" (completed) - Skip adding /btw to conversation history to avoid duplicate display - Prioritize dialog shortcuts over btw dismiss via dialogsVisibleRef - Add `sleep` property to terminal-capture FlowStep for async wait scenarios Made-with: Cursor --- .../terminal-capture/scenario-runner.ts | 23 +++++++++++++++++++ packages/cli/src/ui/AppContainer.tsx | 16 +++++++++---- .../src/ui/components/messages/BtwMessage.tsx | 13 +++++++---- .../cli/src/ui/hooks/slashCommandProcessor.ts | 11 +++++---- .../cli/src/ui/layouts/DefaultAppLayout.tsx | 9 ++++---- .../src/ui/layouts/ScreenReaderAppLayout.tsx | 10 ++++---- 6 files changed, 59 insertions(+), 23 deletions(-) diff --git a/integration-tests/terminal-capture/scenario-runner.ts b/integration-tests/terminal-capture/scenario-runner.ts index 93640694b..ff4920aa7 100644 --- a/integration-tests/terminal-capture/scenario-runner.ts +++ b/integration-tests/terminal-capture/scenario-runner.ts @@ -31,6 +31,24 @@ export interface FlowStep { capture?: string; /** Explicit screenshot: full scrollback buffer long image (standalone capture when no type) */ captureFull?: string; + /** + * Explicit sleep before executing this step (milliseconds). + * + * The runner's built-in idle detection (`idle(2000, 60000)`) works well for + * synchronous streaming, but cannot anticipate async responses that arrive + * after output has already stabilized (e.g., a /btw side-question whose API + * response is serialized behind a main streaming task). In such cases, the + * idle detector triggers too early and the async response is missed. + * + * Use `sleep` to bridge that gap — it inserts a fixed delay before the step + * runs, giving async operations time to complete. Optional; omitting it (or + * setting it to 0) has no effect on existing scenarios. + * + * @example + * // Wait 20s for a /btw response before capturing the result + * { sleep: 20000, capture: 'btw-answered.png' } + */ + sleep?: number; /** * Streaming capture: capture multiple screenshots during execution at intervals. * Useful for demonstrating real-time output like progress bars. @@ -159,6 +177,11 @@ export async function runScenario( const step = config.flow[i]; const label = `[${i + 1}/${config.flow.length}]`; + if (step.sleep && step.sleep > 0) { + console.log(` ${label} 💤 sleep: ${step.sleep}ms`); + await sleep(step.sleep); + } + if (step.type) { const display = step.type.length > 60 ? step.type.slice(0, 60) + '...' : step.type; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 75b937ffd..b1918ebaa 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -958,6 +958,7 @@ export const AppContainer = (props: AppContainerProps) => { const ctrlDTimerRef = useRef(null); const [escapePressedOnce, setEscapePressedOnce] = useState(false); const escapeTimerRef = useRef(null); + const dialogsVisibleRef = useRef(false); const [constrainHeight, setConstrainHeight] = useState(true); const [ideContextState, setIdeContextState] = useState< IdeContext | undefined @@ -1244,8 +1245,9 @@ export const AppContainer = (props: AppContainerProps) => { handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef); return; } else if (keyMatchers[Command.ESCAPE](key)) { - // Dismiss or cancel btw side-question on Escape - if (btwItem) { + // Dismiss or cancel btw side-question on Escape, + // but only when btw is actually visible (not hidden behind a dialog). + if (btwItem && !dialogsVisibleRef.current) { cancelBtw(); return; } @@ -1292,8 +1294,13 @@ export const AppContainer = (props: AppContainerProps) => { } // Dismiss completed btw side-question on Space or Enter, - // but only when the input buffer is empty so we don't swallow user keystrokes. - if (btwItem && !btwItem.btw.isPending && buffer.text.length === 0) { + // but only when btw is visible and the input buffer is empty. + if ( + btwItem && + !btwItem.btw.isPending && + !dialogsVisibleRef.current && + buffer.text.length === 0 + ) { if (key.name === 'return' || key.sequence === ' ') { setBtwItem(null); return; @@ -1430,6 +1437,7 @@ export const AppContainer = (props: AppContainerProps) => { isApprovalModeDialogOpen || isResumeDialogOpen || isExtensionsManagerDialogOpen; + dialogsVisibleRef.current = dialogsVisible; const { isFeedbackDialogOpen, diff --git a/packages/cli/src/ui/components/messages/BtwMessage.tsx b/packages/cli/src/ui/components/messages/BtwMessage.tsx index a172d43fa..9b28ecc49 100644 --- a/packages/cli/src/ui/components/messages/BtwMessage.tsx +++ b/packages/cli/src/ui/components/messages/BtwMessage.tsx @@ -31,12 +31,17 @@ const BtwMessageInternal: React.FC = ({ btw }) => ( {btw.isPending ? ( - - {'+ '} - {t('Answering...')} + + + {'+ '} + {t('Answering...')} + + + {t('Press Escape to cancel')} + ) : ( - + {btw.answer} {t('Press Space, Enter, or Escape to dismiss')} diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 35050623b..2d61409f4 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -37,6 +37,7 @@ import { BundledSkillLoader } from '../../services/BundledSkillLoader.js'; import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; import { parseSlashCommand } from '../../utils/commands.js'; +import { isBtwCommand } from '../utils/commandUtils.js'; import { clearScreen } from '../../utils/stdioHelpers.js'; import { useKeypress } from './useKeypress.js'; import { @@ -385,10 +386,12 @@ export const useSlashCommandProcessor = ( abortControllerRef.current = abortController; const userMessageTimestamp = Date.now(); - addItemWithRecording( - { type: MessageType.USER, text: trimmed }, - userMessageTimestamp, - ); + if (!isBtwCommand(trimmed)) { + addItemWithRecording( + { type: MessageType.USER, text: trimmed }, + userMessageTimestamp, + ); + } let hasError = false; const { diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index afa656ba7..479730cb4 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -55,11 +55,6 @@ export const DefaultAppLayout: React.FC = () => { <> {/* Main view: conversation history + main composer / dialogs */} - {uiState.btwItem && ( - - - - )} {uiState.dialogsVisible ? ( { addItem={uiState.historyManager.addItem} /> + ) : uiState.btwItem ? ( + + + ) : ( )} diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx index 633f631ee..f9e876a48 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx @@ -26,12 +26,6 @@ export const ScreenReaderAppLayout: React.FC = () => { - {uiState.btwItem && ( - - - - )} - {uiState.dialogsVisible ? ( { addItem={uiState.historyManager.addItem} /> + ) : uiState.btwItem ? ( + + + ) : ( )} From 13423f0676688b19727b88693c477207eba0113f Mon Sep 17 00:00:00 2001 From: wenshao Date: Sun, 22 Mar 2026 01:21:07 +0800 Subject: [PATCH 16/16] fix(cli): harden /btw command error handling and type safety - Add null/undefined guard in formatBtwError to avoid "null"/"undefined" strings - Add type guard for btw property in HistoryItemDisplay to prevent crash - Extract isBtwCommand regex to module-level constant and simplify with [/?] Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/commands/btwCommand.ts | 3 ++- packages/cli/src/ui/components/HistoryItemDisplay.tsx | 4 +++- packages/cli/src/ui/utils/commandUtils.ts | 10 +++------- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/commands/btwCommand.ts b/packages/cli/src/ui/commands/btwCommand.ts index 7ee5668df..60a3ab8dd 100644 --- a/packages/cli/src/ui/commands/btwCommand.ts +++ b/packages/cli/src/ui/commands/btwCommand.ts @@ -21,7 +21,8 @@ function makeBtwPromptId(sessionId: string): string { function formatBtwError(error: unknown): string { return t('Failed to answer btw question: {{error}}', { - error: error instanceof Error ? error.message : String(error), + error: + error instanceof Error ? error.message : String(error || 'Unknown error'), }); } diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index d4e8b5b6b..12a46380e 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -227,7 +227,9 @@ const HistoryItemDisplayComponent: React.FC = ({ {itemForDisplay.type === 'insight_progress' && ( )} - {itemForDisplay.type === 'btw' && } + {itemForDisplay.type === 'btw' && itemForDisplay.btw && ( + + )} ); }; diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 69038eaad..9436447f7 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -62,19 +62,15 @@ export const isSlashCommand = (query: string): boolean => { return true; }; +const BTW_COMMAND_RE = /^[/?]btw(?:\s|$)/; + /** * Checks if a query is a /btw side-question invocation. * Accepts both "/btw" and "?btw" prefixes. */ export const isBtwCommand = (query: string): boolean => { const trimmed = query.trim(); - if (!trimmed) { - return false; - } - - const normalized = trimmed.startsWith('?') ? `/${trimmed.slice(1)}` : trimmed; - - return /^\/btw(?:\s|$)/.test(normalized); + return trimmed.length > 0 && BTW_COMMAND_RE.test(trimmed); }; const debugLogger = createDebugLogger('COMMAND_UTILS');