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 {