Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { arenaCommand } from '../ui/commands/arenaCommand.js';
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { branchCommand } from '../ui/commands/branchCommand.js';
import { btwCommand } from '../ui/commands/btwCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
Expand Down Expand Up @@ -97,6 +98,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
arenaCommand,
approvalModeCommand,
authCommand,
branchCommand,
btwCommand,
bugCommand,
clearCommand,
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import { useModelCommand } from './hooks/useModelCommand.js';
import { useManageModelsCommand } from './hooks/useManageModelsCommand.js';
import { useArenaCommand } from './hooks/useArenaCommand.js';
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
import { useBranchCommand } from './hooks/useBranchCommand.js';
import { useResumeCommand } from './hooks/useResumeCommand.js';
import { useDeleteCommand } from './hooks/useDeleteCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
Expand Down Expand Up @@ -683,6 +684,14 @@ export const AppContainer = (props: AppContainerProps) => {
remount: refreshStatic,
});

const { handleBranch } = useBranchCommand({
config,
historyManager,
startNewSession,
setSessionName,
remount: refreshStatic,
});

const {
isDeleteDialogOpen,
openDeleteDialog,
Expand Down Expand Up @@ -751,6 +760,7 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
openRewindSelector: () => openRewindSelectorRef.current(),
handleResume,
handleBranch,
openDeleteDialog,
}),
[
Expand All @@ -775,6 +785,7 @@ export const AppContainer = (props: AppContainerProps) => {
openHooksDialog,
openResumeDialog,
handleResume,
handleBranch,
openDeleteDialog,
],
);
Expand Down Expand Up @@ -2628,6 +2639,8 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Branch (fork) session
handleBranch,
// Delete session dialog
openDeleteDialog,
closeDeleteDialog,
Expand Down Expand Up @@ -2693,6 +2706,8 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Branch (fork) session
handleBranch,
// Delete session dialog
openDeleteDialog,
closeDeleteDialog,
Expand Down
76 changes: 76 additions & 0 deletions packages/cli/src/ui/commands/branchCommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi } from 'vitest';
import { branchCommand } from './branchCommand.js';
import type { CommandContext } from './types.js';

function makeCtx(
overrides: {
isIdle?: boolean;
sessionExists?: boolean;
noConfig?: boolean;
} = {},
): CommandContext {
const sessionService = {
sessionExists: vi.fn().mockResolvedValue(overrides.sessionExists ?? true),
};
const config = overrides.noConfig
? null
: ({
getSessionId: () => '11111111-1111-1111-1111-111111111111',
getSessionService: () => sessionService,
} as unknown as NonNullable<CommandContext['services']['config']>);
return {
services: { config, settings: {} as never, git: undefined, logger: null },
ui: {
isIdleRef: { current: overrides.isIdle ?? true },
} as unknown as CommandContext['ui'],
session: { stats: {} as never, sessionShellAllowlist: new Set() },
} as unknown as CommandContext;
}

describe('branchCommand', () => {
it('rejects when config is unavailable', async () => {
const result = await branchCommand.action!(makeCtx({ noConfig: true }), '');
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
});

it('rejects when no conversation exists to branch from', async () => {
const result = await branchCommand.action!(
makeCtx({ sessionExists: false }),
'',
);
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
expect((result as { content: string }).content).toMatch(
/No conversation to branch/,
);
});

it('rejects while streaming or awaiting a tool confirmation', async () => {
const result = await branchCommand.action!(makeCtx({ isIdle: false }), '');
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
expect((result as { content: string }).content).toMatch(/in progress/);
});

it('returns dialog action with no name when args are empty', async () => {
const result = await branchCommand.action!(makeCtx(), ' ');
expect(result).toEqual({ type: 'dialog', dialog: 'branch' });
});

it('returns dialog action with trimmed name when args are provided', async () => {
const result = await branchCommand.action!(makeCtx(), ' my-branch ');
expect(result).toEqual({
type: 'dialog',
dialog: 'branch',
name: 'my-branch',
});
});

it('exposes /fork as an alias', () => {
expect(branchCommand.altNames).toContain('fork');
});
});
59 changes: 59 additions & 0 deletions packages/cli/src/ui/commands/branchCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/

import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';

export const branchCommand: SlashCommand = {
name: 'branch',
altNames: ['fork'],
kind: CommandKind.BUILT_IN,
get description() {
return t('Fork the current conversation into a new session');
},
action: async (context, args): Promise<SlashCommandActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config is not available.'),
};
}

// Guard: streaming or awaiting tool confirmation — forking mid-flight
// would tear the new session's parent chain.
if (context.ui.isIdleRef?.current === false) {
return {
type: 'message',
messageType: 'error',
content: t(
'Cannot branch while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.',
),
};
}

// Guard: nothing to fork from.
const sessionService = config.getSessionService();
const currentId = config.getSessionId();
const hasRecords = await sessionService.sessionExists(currentId);
if (!hasRecords) {
return {
type: 'message',
messageType: 'error',
content: t('No conversation to branch.'),
};
}

const name = args.trim().replace(/[\r\n]+/g, ' ');
return (
name
? { type: 'dialog', dialog: 'branch', name }
: { type: 'dialog', dialog: 'branch' }
) as SlashCommandActionReturn;
},
};
4 changes: 4 additions & 0 deletions packages/cli/src/ui/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ export interface OpenDialogActionReturn {
/** Pre-filtered sessions for the picker (e.g., multiple title matches from /resume <title>). */
matchedSessions?: SessionListItem[];

/** Optional session name for /branch — passed through to handleBranch. */
name?: string;

dialog:
| 'help'
| 'arena_start'
Expand All @@ -181,6 +184,7 @@ export interface OpenDialogActionReturn {
| 'approval-mode'
| 'resume'
| 'delete'
| 'branch'
| 'extensions_manage'
| 'hooks'
| 'mcp'
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/contexts/UIActionsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export interface UIActions {
openResumeDialog: () => void;
closeResumeDialog: () => void;
handleResume: (sessionId: string) => void;
// Branch (fork) session
handleBranch: (name?: string) => Promise<void>;
// Delete session dialog
openDeleteDialog: () => void;
closeDeleteDialog: () => void;
Expand Down
54 changes: 51 additions & 3 deletions packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ describe('useSlashCommandProcessor', () => {
openApprovalModeDialog: vi.fn(),
openResumeDialog: vi.fn(),
handleResume: vi.fn(),
handleBranch: vi.fn().mockResolvedValue(undefined),
openDeleteDialog: vi.fn(),
quit: mockSetQuittingMessages,
setDebugMessage: vi.fn(),
Expand Down Expand Up @@ -503,7 +504,6 @@ describe('useSlashCommandProcessor', () => {
it('should handle "load_history" action', async () => {
const mockClient = {
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
} as unknown as GeminiClient;
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);

Expand Down Expand Up @@ -532,7 +532,6 @@ describe('useSlashCommandProcessor', () => {
it('should preserve thoughts when handling "load_history" action', async () => {
const mockClient = {
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
} as unknown as GeminiClient;
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);

Expand All @@ -559,7 +558,7 @@ describe('useSlashCommandProcessor', () => {
});

expect(mockClient.setHistory).toHaveBeenCalledTimes(1);
expect(mockClient.stripThoughtsFromHistory).not.toHaveBeenCalled();
expect(mockClient.setHistory).toHaveBeenCalledWith(historyWithThoughts);
});

it('should handle a "quit" action', async () => {
Expand Down Expand Up @@ -1187,4 +1186,53 @@ describe('useSlashCommandProcessor', () => {
).toBeNull();
});
});

describe('SLASH_COMMANDS_SKIP_RECORDING', () => {
// Why these live in the skip set: the fork itself is the side effect
// (new JSONL file with full parent history), so also writing a
// `/branch <name>` slash-command record into the parent session would
// bleed into the fork's tail as a trailing user input — user-visible
// noise with no semantic value. Same rationale for /new, /resume,
// /delete, /clear: session-level commands whose outcome is the new
// session state, not a conversation turn.
it('does not record /branch via the chat recorder', async () => {
const branchCmd = createTestCommand({
name: 'branch',
action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'branch' }),
});
const result = setupProcessorHook([branchCmd]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

const recorder = mockConfig.getChatRecordingService() as unknown as {
recordSlashCommand: ReturnType<typeof vi.fn>;
};
recorder.recordSlashCommand.mockClear();

await act(async () => {
await result.current.handleSlashCommand('/branch my-branch');
});

expect(recorder.recordSlashCommand).not.toHaveBeenCalled();
});

it('still records unrelated commands via the chat recorder (control)', async () => {
const testCmd = createTestCommand({
name: 'regular',
action: vi.fn().mockResolvedValue(undefined),
});
const result = setupProcessorHook([testCmd]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

const recorder = mockConfig.getChatRecordingService() as unknown as {
recordSlashCommand: ReturnType<typeof vi.fn>;
};
recorder.recordSlashCommand.mockClear();

await act(async () => {
await result.current.handleSlashCommand('/regular');
});

expect(recorder.recordSlashCommand).toHaveBeenCalled();
});
});
});
11 changes: 11 additions & 0 deletions packages/cli/src/ui/hooks/slashCommandProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
'new',
'resume',
'delete',
'branch',
'btw',
]);

Expand All @@ -95,6 +96,7 @@ export interface SlashCommandProcessorActions {
openApprovalModeDialog: () => void;
openResumeDialog: (matchedSessions?: SessionListItem[]) => void;
handleResume: (sessionId: string) => void;
handleBranch: (name?: string) => Promise<void>;
openDeleteDialog: () => void;
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
Expand Down Expand Up @@ -633,6 +635,15 @@ export const useSlashCommandProcessor = (
actions.openResumeDialog(result.matchedSessions);
}
return { type: 'handled' };
case 'branch':
// Must be awaited: `/branch` swaps core + UI session
// state asynchronously, and a non-awaited call lets
// this dispatcher return `handled` while the swap is
// still in flight. A fast follow-up prompt could then
// interleave with the swap and be recorded against
// the wrong session.
await actions.handleBranch(result.name);
return { type: 'handled' };
case 'delete':
actions.openDeleteDialog();
return { type: 'handled' };
Expand Down
Loading
Loading