Skip to content
65 changes: 65 additions & 0 deletions cli/src/commands/__tests__/btw-attachments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'

import { COMMAND_REGISTRY } from '../command-registry'
import { useChatStore } from '../../state/chat-store'

import type { RouterParams } from '../command-registry'

function createMockParams(
overrides: Partial<RouterParams> = {},
): RouterParams {
return {
agentMode: 'DEFAULT',
inputRef: { current: null },
inputValue: '/btw check the parser',
isChainInProgressRef: { current: false },
isStreaming: false,
logoutMutation: {} as RouterParams['logoutMutation'],
streamMessageIdRef: { current: null },
addToQueue: mock(() => {}),
clearMessages: mock(() => {}),
saveToHistory: mock(() => {}),
scrollToLatest: mock(() => {}),
sendMessage: mock(async () => {}),
setCanProcessQueue: mock(() => {}),
setInputFocused: mock(() => {}),
setInputValue: mock(() => {}),
setIsAuthenticated: mock(() => {}),
setMessages: mock(() => {}),
setUser: mock(() => {}),
...overrides,
}
}

describe('/btw attachment routing', () => {
afterEach(() => {
useChatStore.getState().clearPendingAttachments()
})

test('leaves idle attachments staged for sendMessage to consume', () => {
const btwCmd = COMMAND_REGISTRY.find((command) => command.name === 'btw')
expect(btwCmd).toBeDefined()

const attachment = {
kind: 'text' as const,
id: 'note.txt',
content: 'remember the edge case',
preview: 'remember the edge case',
charCount: 22,
}
useChatStore.getState().addPendingAttachment(attachment)

const sendMessage = mock(async () => {})
btwCmd!.handler(createMockParams({ sendMessage }), 'check the parser')

expect(sendMessage).toHaveBeenCalledTimes(1)
// sendMessage intentionally receives no explicit `attachments` value here:
// prepareUserMessage falls back to useChatStore.pendingAttachments and clears
// that store only after it has captured the attachments for the direct send.
expect(sendMessage).toHaveBeenCalledWith({
content: expect.stringContaining('check the parser'),
agentMode: 'DEFAULT',
})
expect(useChatStore.getState().pendingAttachments).toEqual([attachment])
})
})
85 changes: 84 additions & 1 deletion cli/src/commands/__tests__/command-args.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, test, expect, mock } from 'bun:test'
import { afterEach, describe, test, expect, mock } from 'bun:test'

import { useFeedbackStore } from '../../state/feedback-store'
import { useChatStore } from '../../state/chat-store'
import {
registerActiveRun,
stopActiveRun,
Expand Down Expand Up @@ -178,6 +179,7 @@ describe('command factory pattern', () => {
// mode:* commands also accept args now
const expectedWithArgs = [
'feedback',
'btw',
'bash',
'image',
'publish',
Expand Down Expand Up @@ -346,4 +348,85 @@ describe('command factory pattern', () => {
expect(result).toEqual({ openFeedbackMode: true })
})
})

describe('/btw command', () => {
afterEach(() => {
useChatStore.getState().clearPendingAttachments()
})

test('queues the note and pending attachments while a turn is active', () => {
const btwCmd = COMMAND_REGISTRY.find((c) => c.name === 'btw')
expect(btwCmd).toBeDefined()

const attachment = {
kind: 'text' as const,
id: 'note.txt',
content: 'remember the edge case',
preview: 'remember the edge case',
charCount: 22,
}
useChatStore.getState().clearPendingAttachments()
useChatStore.getState().addPendingAttachment(attachment)

const addToQueue = mock(() => {})
const sendMessage = mock(async () => {})
const setInputFocused = mock(() => {})
const params = createMockParams({
inputValue: '/btw remember the edge case',
isStreaming: true,
addToQueue,
sendMessage,
setInputFocused,
})

btwCmd!.handler(params, 'remember the edge case')

expect(addToQueue).toHaveBeenCalledWith(
expect.stringContaining('remember the edge case'),
[attachment],
)
expect(sendMessage).not.toHaveBeenCalled()
expect(setInputFocused).toHaveBeenCalledWith(true)
expect(useChatStore.getState().pendingAttachments).toEqual([])
})

test('sends the note immediately when the CLI is idle', () => {
const btwCmd = COMMAND_REGISTRY.find((c) => c.name === 'btw')
expect(btwCmd).toBeDefined()

const addToQueue = mock(() => {})
const sendMessage = mock(async () => {})
const params = createMockParams({
inputValue: '/btw check the parser',
addToQueue,
sendMessage,
})

btwCmd!.handler(params, 'check the parser')

expect(sendMessage).toHaveBeenCalledWith({
content: expect.stringContaining('check the parser'),
agentMode: 'DEFAULT',
})
expect(addToQueue).not.toHaveBeenCalled()
})

test('shows usage instead of sending an empty note', () => {
const btwCmd = COMMAND_REGISTRY.find((c) => c.name === 'btw')
expect(btwCmd).toBeDefined()

const setMessages = mock(() => {})
const sendMessage = mock(async () => {})
const params = createMockParams({
inputValue: '/btw',
setMessages,
sendMessage,
})

btwCmd!.handler(params, '')

expect(setMessages).toHaveBeenCalled()
expect(sendMessage).not.toHaveBeenCalled()
})
})
})
7 changes: 7 additions & 0 deletions cli/src/commands/__tests__/prompt-builders.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { describe, expect, test } from 'bun:test'

import {
buildBtwPrompt,
buildPlanPrompt,
buildReviewPrompt,
buildReviewPromptFromArgs,
} from '../prompt-builders'

describe('prompt-builders base prompts', () => {
test('/btw keeps the note and removes command whitespace', () => {
expect(buildBtwPrompt(' remember to run tests ')).toBe(
'The user has an additional thought for the current task. Consider it without abandoning the original request:\n\nremember to run tests',
)
})

// These used to branch on whether the user had connected a ChatGPT account,
// delegating the deep-thinking step to @thinker-gpt if so. That integration
// is gone, so there is one branch: the user's selected model does the work.
Expand Down
49 changes: 47 additions & 2 deletions cli/src/commands/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import {
collectProcessDiagnostics,
formatProcessDiagnostics,
} from './process-diagnostics'
import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs } from './prompt-builders'
import {
buildBtwPrompt,
buildInterviewPrompt,
buildPlanPrompt,
buildReviewPromptFromArgs,
} from './prompt-builders'
import { handleReasoningCommand } from './reasoning'
import { runBashCommand } from './router'
import { handleUsageCommand } from './usage'
Expand Down Expand Up @@ -598,6 +603,46 @@ const ALL_COMMANDS: CommandDefinition[] = [
return { openReviewScreen: true }
},
}),
defineCommandWithArgs({
name: 'btw',
handler: (params, args) => {
const trimmedArgs = args.trim()
const rawInput = params.inputValue.trim()

params.saveToHistory(rawInput)
clearInput(params)

if (!trimmedArgs) {
params.setMessages((prev) => [
...prev,
getSystemMessage('Usage: /btw <additional thought>'),
])
return
}

const btwPrompt = buildBtwPrompt(trimmedArgs)
const isBusy =
params.isStreaming ||
params.streamMessageIdRef.current ||
params.isChainInProgressRef.current

if (isBusy) {
const pendingAttachments = capturePendingAttachments()
params.addToQueue(btwPrompt, pendingAttachments)
params.setInputFocused(true)
params.inputRef.current?.focus()
return
}

params.sendMessage({
content: btwPrompt,
agentMode: params.agentMode,
})
setTimeout(() => {
params.scrollToLatest()
}, 0)
},
}),
defineCommand({
// No `/q` alias: that one already quits the CLI, and a queue editor is not
// worth the chance of a mis-fired exit.
Expand Down Expand Up @@ -747,4 +792,4 @@ ${skill.content}
}, 0)
},
})
}
}
14 changes: 12 additions & 2 deletions cli/src/commands/prompt-builders.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Centralized prompt builders for /plan and /review commands.
* Centralized prompt builders for /plan, /review, and /btw commands.
* This ensures consistent behavior regardless of entry path. Both run on the
* user's currently selected model.
*/
Expand All @@ -10,6 +10,17 @@ const PLAN_BASE_PROMPT =
const REVIEW_BASE_PROMPT =
'Please gather all relevant context and then carefully review:'

const BTW_BASE_PROMPT =
'The user has an additional thought for the current task. Consider it without abandoning the original request:'

/** Build the prompt sent by `/btw` without the command syntax. */
export function buildBtwPrompt(input: string): string {
const trimmedInput = input.trim()
return trimmedInput
? `${BTW_BASE_PROMPT}\n\n${trimmedInput}`
: BTW_BASE_PROMPT
}

/**
* Build a plan prompt from user input.
* @param input - The user's plan request (e.g., "add OAuth login")
Expand Down Expand Up @@ -97,4 +108,3 @@ export function buildReviewPromptFromArgs(input: string): string {
// Use the same format as preset scopes for consistency
return `${REVIEW_BASE_PROMPT} ${trimmedInput}`
}

6 changes: 6 additions & 0 deletions cli/src/data/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [
label: 'review',
description: 'Review code changes',
},
{
id: 'btw',
label: 'btw',
description:
'Queue an additional thought without interrupting the current task',
},
{
id: 'queue',
label: 'queue',
Expand Down
Loading