Rename /branch command to /fork

- RPC: branch -> fork, get_branch_messages -> get_fork_messages
- SDK: branch() -> fork(), getBranchMessages() -> getForkMessages()
- AgentSession: branch() -> fork(), getUserMessagesForBranching() -> getUserMessagesForForking()
- Extension events: session_before_branch -> session_before_fork, session_branch -> session_fork
- Settings: doubleEscapeAction 'branch' -> 'fork'

fixes #641
This commit is contained in:
Mario Zechner 2026-01-11 23:12:18 +01:00
parent e7352a50bf
commit df3f5f41c0
27 changed files with 162 additions and 156 deletions

View file

@ -37,8 +37,8 @@ import {
import { exportSessionToHtml } from "./export-html/index.js";
import type {
ExtensionRunner,
SessionBeforeBranchResult,
SessionBeforeCompactResult,
SessionBeforeForkResult,
SessionBeforeSwitchResult,
SessionBeforeTreeResult,
TreePreparation,
@ -1831,32 +1831,32 @@ export class AgentSession {
}
/**
* Create a branch from a specific entry.
* Emits before_branch/branch session events to extensions.
* Create a fork from a specific entry.
* Emits before_fork/fork session events to extensions.
*
* @param entryId ID of the entry to branch from
* @param entryId ID of the entry to fork from
* @returns Object with:
* - selectedText: The text of the selected user message (for editor pre-fill)
* - cancelled: True if an extension cancelled the branch
* - cancelled: True if an extension cancelled the fork
*/
async branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }> {
async fork(entryId: string): Promise<{ selectedText: string; cancelled: boolean }> {
const previousSessionFile = this.sessionFile;
const selectedEntry = this.sessionManager.getEntry(entryId);
if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
throw new Error("Invalid entry ID for branching");
throw new Error("Invalid entry ID for forking");
}
const selectedText = this._extractUserMessageText(selectedEntry.message.content);
let skipConversationRestore = false;
// Emit session_before_branch event (can be cancelled)
if (this._extensionRunner?.hasHandlers("session_before_branch")) {
// Emit session_before_fork event (can be cancelled)
if (this._extensionRunner?.hasHandlers("session_before_fork")) {
const result = (await this._extensionRunner.emit({
type: "session_before_branch",
type: "session_before_fork",
entryId,
})) as SessionBeforeBranchResult | undefined;
})) as SessionBeforeForkResult | undefined;
if (result?.cancel) {
return { selectedText, cancelled: true };
@ -1877,15 +1877,15 @@ export class AgentSession {
// Reload messages from entries (works for both file and in-memory mode)
const sessionContext = this.sessionManager.buildSessionContext();
// Emit session_branch event to extensions (after branch completes)
// Emit session_fork event to extensions (after fork completes)
if (this._extensionRunner) {
await this._extensionRunner.emit({
type: "session_branch",
type: "session_fork",
previousSessionFile,
});
}
// Emit session event to custom tools (with reason "branch")
// Emit session event to custom tools (with reason "fork")
if (!skipConversationRestore) {
this.agent.replaceMessages(sessionContext.messages);
@ -1900,7 +1900,7 @@ export class AgentSession {
/**
* Navigate to a different node in the session tree.
* Unlike branch() which creates a new session file, this stays in the same file.
* Unlike fork() which creates a new session file, this stays in the same file.
*
* @param targetId The entry ID to navigate to
* @param options.summarize Whether user wants to summarize abandoned branch
@ -2061,9 +2061,9 @@ export class AgentSession {
}
/**
* Get all user messages from session for branch selector.
* Get all user messages from session for fork selector.
*/
getUserMessagesForBranching(): Array<{ entryId: string; text: string }> {
getUserMessagesForForking(): Array<{ entryId: string; text: string }> {
const entries = this.sessionManager.getEntries();
const result: Array<{ entryId: string; text: string }> = [];

View file

@ -9,8 +9,8 @@ export {
loadExtensions,
} from "./loader.js";
export type {
BranchHandler,
ExtensionErrorListener,
ForkHandler,
NavigateTreeHandler,
NewSessionHandler,
ShutdownHandler,
@ -75,17 +75,17 @@ export type {
RegisteredTool,
SendMessageHandler,
SendUserMessageHandler,
SessionBeforeBranchEvent,
SessionBeforeBranchResult,
SessionBeforeCompactEvent,
SessionBeforeCompactResult,
SessionBeforeForkEvent,
SessionBeforeForkResult,
SessionBeforeSwitchEvent,
SessionBeforeSwitchResult,
SessionBeforeTreeEvent,
SessionBeforeTreeResult,
SessionBranchEvent,
SessionCompactEvent,
SessionEvent,
SessionForkEvent,
SessionShutdownEvent,
// Events - Session
SessionStartEvent,

View file

@ -50,7 +50,7 @@ export type NewSessionHandler = (options?: {
setup?: (sessionManager: SessionManager) => Promise<void>;
}) => Promise<{ cancelled: boolean }>;
export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>;
export type ForkHandler = (entryId: string) => Promise<{ cancelled: boolean }>;
export type NavigateTreeHandler = (
targetId: string,
@ -111,7 +111,7 @@ export class ExtensionRunner {
private abortFn: () => void = () => {};
private hasPendingMessagesFn: () => boolean = () => false;
private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
private branchHandler: BranchHandler = async () => ({ cancelled: false });
private forkHandler: ForkHandler = async () => ({ cancelled: false });
private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
private shutdownHandler: ShutdownHandler = () => {};
@ -158,7 +158,7 @@ export class ExtensionRunner {
if (commandContextActions) {
this.waitForIdleFn = commandContextActions.waitForIdle;
this.newSessionHandler = commandContextActions.newSession;
this.branchHandler = commandContextActions.branch;
this.forkHandler = commandContextActions.fork;
this.navigateTreeHandler = commandContextActions.navigateTree;
}
this.uiContext = uiContext ?? noOpUIContext;
@ -329,17 +329,17 @@ export class ExtensionRunner {
...this.createContext(),
waitForIdle: () => this.waitForIdleFn(),
newSession: (options) => this.newSessionHandler(options),
branch: (entryId) => this.branchHandler(entryId),
fork: (entryId) => this.forkHandler(entryId),
navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options),
};
}
private isSessionBeforeEvent(
type: string,
): type is "session_before_switch" | "session_before_branch" | "session_before_compact" | "session_before_tree" {
): type is "session_before_switch" | "session_before_fork" | "session_before_compact" | "session_before_tree" {
return (
type === "session_before_switch" ||
type === "session_before_branch" ||
type === "session_before_fork" ||
type === "session_before_compact" ||
type === "session_before_tree"
);

View file

@ -218,8 +218,8 @@ export interface ExtensionCommandContext extends ExtensionContext {
setup?: (sessionManager: SessionManager) => Promise<void>;
}): Promise<{ cancelled: boolean }>;
/** Branch from a specific entry, creating a new session file. */
branch(entryId: string): Promise<{ cancelled: boolean }>;
/** Fork from a specific entry, creating a new session file. */
fork(entryId: string): Promise<{ cancelled: boolean }>;
/** Navigate to a different point in the session tree. */
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>;
@ -289,15 +289,15 @@ export interface SessionSwitchEvent {
previousSessionFile: string | undefined;
}
/** Fired before branching a session (can be cancelled) */
export interface SessionBeforeBranchEvent {
type: "session_before_branch";
/** Fired before forking a session (can be cancelled) */
export interface SessionBeforeForkEvent {
type: "session_before_fork";
entryId: string;
}
/** Fired after branching a session */
export interface SessionBranchEvent {
type: "session_branch";
/** Fired after forking a session */
export interface SessionForkEvent {
type: "session_fork";
previousSessionFile: string | undefined;
}
@ -351,8 +351,8 @@ export type SessionEvent =
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
| SessionBeforeBranchEvent
| SessionBranchEvent
| SessionBeforeForkEvent
| SessionForkEvent
| SessionBeforeCompactEvent
| SessionCompactEvent
| SessionShutdownEvent
@ -577,7 +577,7 @@ export interface SessionBeforeSwitchResult {
cancel?: boolean;
}
export interface SessionBeforeBranchResult {
export interface SessionBeforeForkResult {
cancel?: boolean;
skipConversationRestore?: boolean;
}
@ -641,11 +641,8 @@ export interface ExtensionAPI {
handler: ExtensionHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>,
): void;
on(event: "session_switch", handler: ExtensionHandler<SessionSwitchEvent>): void;
on(
event: "session_before_branch",
handler: ExtensionHandler<SessionBeforeBranchEvent, SessionBeforeBranchResult>,
): void;
on(event: "session_branch", handler: ExtensionHandler<SessionBranchEvent>): void;
on(event: "session_before_fork", handler: ExtensionHandler<SessionBeforeForkEvent, SessionBeforeForkResult>): void;
on(event: "session_fork", handler: ExtensionHandler<SessionForkEvent>): void;
on(
event: "session_before_compact",
handler: ExtensionHandler<SessionBeforeCompactEvent, SessionBeforeCompactResult>,
@ -858,7 +855,7 @@ export interface ExtensionCommandContextActions {
parentSession?: string;
setup?: (sessionManager: SessionManager) => Promise<void>;
}) => Promise<{ cancelled: boolean }>;
branch: (entryId: string) => Promise<{ cancelled: boolean }>;
fork: (entryId: string) => Promise<{ cancelled: boolean }>;
navigateTree: (targetId: string, options?: { summarize?: boolean }) => Promise<{ cancelled: boolean }>;
}

View file

@ -41,12 +41,12 @@ export {
type LoadExtensionsResult,
type MessageRenderer,
type RegisteredCommand,
type SessionBeforeBranchEvent,
type SessionBeforeCompactEvent,
type SessionBeforeForkEvent,
type SessionBeforeSwitchEvent,
type SessionBeforeTreeEvent,
type SessionBranchEvent,
type SessionCompactEvent,
type SessionForkEvent,
type SessionShutdownEvent,
type SessionStartEvent,
type SessionSwitchEvent,

View file

@ -66,7 +66,7 @@ export interface Settings {
terminal?: TerminalSettings;
images?: ImageSettings;
enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
doubleEscapeAction?: "branch" | "tree"; // Action for double-escape with empty editor (default: "tree")
doubleEscapeAction?: "fork" | "tree"; // Action for double-escape with empty editor (default: "tree")
thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels
}
@ -452,11 +452,11 @@ export class SettingsManager {
this.save();
}
getDoubleEscapeAction(): "branch" | "tree" {
getDoubleEscapeAction(): "fork" | "tree" {
return this.settings.doubleEscapeAction ?? "tree";
}
setDoubleEscapeAction(action: "branch" | "tree"): void {
setDoubleEscapeAction(action: "fork" | "tree"): void {
this.globalSettings.doubleEscapeAction = action;
this.save();
}

View file

@ -67,12 +67,12 @@ export type {
MessageRenderOptions,
RegisteredCommand,
RegisteredTool,
SessionBeforeBranchEvent,
SessionBeforeCompactEvent,
SessionBeforeForkEvent,
SessionBeforeSwitchEvent,
SessionBeforeTreeEvent,
SessionBranchEvent,
SessionCompactEvent,
SessionForkEvent,
SessionShutdownEvent,
SessionStartEvent,
SessionSwitchEvent,

View file

@ -35,7 +35,7 @@ export interface SettingsConfig {
availableThemes: string[];
hideThinkingBlock: boolean;
collapseChangelog: boolean;
doubleEscapeAction: "branch" | "tree";
doubleEscapeAction: "fork" | "tree";
}
export interface SettingsCallbacks {
@ -51,7 +51,7 @@ export interface SettingsCallbacks {
onThemePreview?: (theme: string) => void;
onHideThinkingBlockChange: (hidden: boolean) => void;
onCollapseChangelogChange: (collapsed: boolean) => void;
onDoubleEscapeActionChange: (action: "branch" | "tree") => void;
onDoubleEscapeActionChange: (action: "fork" | "tree") => void;
onCancel: () => void;
}
@ -171,7 +171,7 @@ export class SettingsSelectorComponent extends Container {
label: "Double-escape action",
description: "Action when pressing Escape twice with empty editor",
currentValue: config.doubleEscapeAction,
values: ["tree", "branch"],
values: ["tree", "fork"],
},
{
id: "thinking",
@ -304,7 +304,7 @@ export class SettingsSelectorComponent extends Container {
callbacks.onCollapseChangelogChange(newValue === "true");
break;
case "double-escape-action":
callbacks.onDoubleEscapeActionChange(newValue as "branch" | "tree");
callbacks.onDoubleEscapeActionChange(newValue as "fork" | "tree");
break;
}
},

View file

@ -289,7 +289,7 @@ export class InteractiveMode {
{ name: "session", description: "Show session info and stats" },
{ name: "changelog", description: "Show changelog entries" },
{ name: "hotkeys", description: "Show all keyboard shortcuts" },
{ name: "branch", description: "Create a new branch from a previous message" },
{ name: "fork", description: "Create a new fork from a previous message" },
{ name: "tree", description: "Navigate session tree (switch branches)" },
{ name: "login", description: "Login with OAuth provider" },
{ name: "logout", description: "Logout from OAuth provider" },
@ -730,8 +730,8 @@ export class InteractiveMode {
return { cancelled: false };
},
branch: async (entryId) => {
const result = await this.session.branch(entryId);
fork: async (entryId) => {
const result = await this.session.fork(entryId);
if (result.cancelled) {
return { cancelled: true };
}
@ -739,7 +739,7 @@ export class InteractiveMode {
this.chatContainer.clear();
this.renderInitialMessages();
this.editor.setText(result.selectedText);
this.showStatus("Branched to new session");
this.showStatus("Forked to new session");
return { cancelled: false };
},
@ -1336,7 +1336,7 @@ export class InteractiveMode {
this.isBashMode = false;
this.updateEditorBorderColor();
} else if (!this.editor.getText().trim()) {
// Double-escape with empty editor triggers /tree or /branch based on setting
// Double-escape with empty editor triggers /tree or /fork based on setting
const now = Date.now();
if (now - this.lastEscapeTime < 500) {
if (this.settingsManager.getDoubleEscapeAction() === "tree") {
@ -1456,7 +1456,7 @@ export class InteractiveMode {
this.editor.setText("");
return;
}
if (text === "/branch") {
if (text === "/fork") {
this.showUserMessageSelector();
this.editor.setText("");
return;
@ -2738,10 +2738,10 @@ export class InteractiveMode {
}
private showUserMessageSelector(): void {
const userMessages = this.session.getUserMessagesForBranching();
const userMessages = this.session.getUserMessagesForForking();
if (userMessages.length === 0) {
this.showStatus("No messages to branch from");
this.showStatus("No messages to fork from");
return;
}
@ -2749,9 +2749,9 @@ export class InteractiveMode {
const selector = new UserMessageSelectorComponent(
userMessages.map((m) => ({ id: m.entryId, text: m.text })),
async (entryId) => {
const result = await this.session.branch(entryId);
const result = await this.session.fork(entryId);
if (result.cancelled) {
// Extension cancelled the branch
// Extension cancelled the fork
done();
this.ui.requestRender();
return;

View file

@ -78,8 +78,8 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
}
return { cancelled: !success };
},
branch: async (entryId) => {
const result = await session.branch(entryId);
fork: async (entryId) => {
const result = await session.fork(entryId);
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, options) => {

View file

@ -338,19 +338,19 @@ export class RpcClient {
}
/**
* Branch from a specific message.
* Fork from a specific message.
* @returns Object with `text` (the message text) and `cancelled` (if extension cancelled)
*/
async branch(entryId: string): Promise<{ text: string; cancelled: boolean }> {
const response = await this.send({ type: "branch", entryId });
async fork(entryId: string): Promise<{ text: string; cancelled: boolean }> {
const response = await this.send({ type: "fork", entryId });
return this.getData(response);
}
/**
* Get messages available for branching.
* Get messages available for forking.
*/
async getBranchMessages(): Promise<Array<{ entryId: string; text: string }>> {
const response = await this.send({ type: "get_branch_messages" });
async getForkMessages(): Promise<Array<{ entryId: string; text: string }>> {
const response = await this.send({ type: "get_fork_messages" });
return this.getData<{ messages: Array<{ entryId: string; text: string }> }>(response).messages;
}

View file

@ -300,8 +300,8 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
}
return { cancelled: !success };
},
branch: async (entryId) => {
const result = await session.branch(entryId);
fork: async (entryId) => {
const result = await session.fork(entryId);
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, options) => {
@ -508,14 +508,14 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return success(id, "switch_session", { cancelled });
}
case "branch": {
const result = await session.branch(command.entryId);
return success(id, "branch", { text: result.selectedText, cancelled: result.cancelled });
case "fork": {
const result = await session.fork(command.entryId);
return success(id, "fork", { text: result.selectedText, cancelled: result.cancelled });
}
case "get_branch_messages": {
const messages = session.getUserMessagesForBranching();
return success(id, "get_branch_messages", { messages });
case "get_fork_messages": {
const messages = session.getUserMessagesForForking();
return success(id, "get_fork_messages", { messages });
}
case "get_last_assistant_text": {

View file

@ -55,8 +55,8 @@ export type RpcCommand =
| { id?: string; type: "get_session_stats" }
| { id?: string; type: "export_html"; outputPath?: string }
| { id?: string; type: "switch_session"; sessionPath: string }
| { id?: string; type: "branch"; entryId: string }
| { id?: string; type: "get_branch_messages" }
| { id?: string; type: "fork"; entryId: string }
| { id?: string; type: "get_fork_messages" }
| { id?: string; type: "get_last_assistant_text" }
// Messages
@ -149,11 +149,11 @@ export type RpcResponse =
| { id?: string; type: "response"; command: "get_session_stats"; success: true; data: SessionStats }
| { id?: string; type: "response"; command: "export_html"; success: true; data: { path: string } }
| { id?: string; type: "response"; command: "switch_session"; success: true; data: { cancelled: boolean } }
| { id?: string; type: "response"; command: "branch"; success: true; data: { text: string; cancelled: boolean } }
| { id?: string; type: "response"; command: "fork"; success: true; data: { text: string; cancelled: boolean } }
| {
id?: string;
type: "response";
command: "get_branch_messages";
command: "get_fork_messages";
success: true;
data: { messages: Array<{ entryId: string; text: string }> };
}