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) <noreply@anthropic.com>
This commit is contained in:
Shaojin Wen 2026-03-14 15:50:41 +08:00
parent 1359563f45
commit 3818f8acd4
6 changed files with 201 additions and 1 deletions

View file

@ -11,6 +11,7 @@ import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js'; import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js'; import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
import { authCommand } from '../ui/commands/authCommand.js'; import { authCommand } from '../ui/commands/authCommand.js';
import { btwCommand } from '../ui/commands/btwCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js'; import { bugCommand } from '../ui/commands/bugCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js';
import { compressCommand } from '../ui/commands/compressCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js';
@ -63,6 +64,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
agentsCommand, agentsCommand,
approvalModeCommand, approvalModeCommand,
authCommand, authCommand,
btwCommand,
bugCommand, bugCommand,
clearCommand, clearCommand,
compressCommand, compressCommand,

View file

@ -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<void | SlashCommandActionReturn> => {
const question = args.trim();
if (!question) {
return {
type: 'message',
messageType: 'error',
content: t('Please provide a question. Usage: /btw <your question>'),
};
}
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(),
);
}
},
};

View file

@ -39,6 +39,7 @@ import { SkillsList } from './views/SkillsList.js';
import { ToolsList } from './views/ToolsList.js'; import { ToolsList } from './views/ToolsList.js';
import { McpStatus } from './views/McpStatus.js'; import { McpStatus } from './views/McpStatus.js';
import { InsightProgressMessage } from './messages/InsightProgressMessage.js'; import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
import { BtwMessage } from './messages/BtwMessage.js';
interface HistoryItemDisplayProps { interface HistoryItemDisplayProps {
item: HistoryItem; item: HistoryItem;
@ -194,6 +195,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'insight_progress' && ( {itemForDisplay.type === 'insight_progress' && (
<InsightProgressMessage progress={itemForDisplay.progress} /> <InsightProgressMessage progress={itemForDisplay.progress} />
)} )}
{itemForDisplay.type === 'btw' && <BtwMessage btw={itemForDisplay.btw} />}
</Box> </Box>
); );
}; };

View file

@ -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<BtwDisplayProps> = ({ btw }) => (
<Box flexDirection="column">
<Box flexDirection="row">
<Text color={Colors.Gray} dimColor>
{'btw> '}
</Text>
<Text color={Colors.Gray}>{btw.question}</Text>
</Box>
<Box flexDirection="row" marginTop={0}>
{btw.isPending ? (
<Box>
<Box marginRight={1}>
<Spinner type="dots" />
</Box>
<Text color={Colors.AccentPurple}>Thinking...</Text>
</Box>
) : (
<Box flexDirection="column">
<Text color={Colors.AccentCyan}>{btw.answer}</Text>
</Box>
)}
</Box>
</Box>
);

View file

@ -62,6 +62,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
'reset', 'reset',
'new', 'new',
'resume', 'resume',
'btw',
]); ]);
interface SlashCommandProcessorActions { interface SlashCommandProcessorActions {

View file

@ -262,6 +262,17 @@ export type HistoryItemInsightProgress = HistoryItemBase & {
progress: InsightProgressProps; progress: InsightProgressProps;
}; };
export interface BtwProps {
question: string;
answer: string;
isPending: boolean;
}
export type HistoryItemBtw = HistoryItemBase & {
type: 'btw';
btw: BtwProps;
};
// Using Omit<HistoryItem, 'id'> seems to have some issues with typescript's // Using Omit<HistoryItem, 'id'> seems to have some issues with typescript's
// type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that // type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that
// 'tools' in historyItem. // 'tools' in historyItem.
@ -291,7 +302,8 @@ export type HistoryItemWithoutId =
| HistoryItemToolsList | HistoryItemToolsList
| HistoryItemSkillsList | HistoryItemSkillsList
| HistoryItemMcpStatus | HistoryItemMcpStatus
| HistoryItemInsightProgress; | HistoryItemInsightProgress
| HistoryItemBtw;
export type HistoryItem = HistoryItemWithoutId & { id: number }; export type HistoryItem = HistoryItemWithoutId & { id: number };
@ -315,6 +327,7 @@ export enum MessageType {
SKILLS_LIST = 'skills_list', SKILLS_LIST = 'skills_list',
MCP_STATUS = 'mcp_status', MCP_STATUS = 'mcp_status',
INSIGHT_PROGRESS = 'insight_progress', INSIGHT_PROGRESS = 'insight_progress',
BTW = 'btw',
} }
export interface InsightProgressProps { export interface InsightProgressProps {