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

@ -2,6 +2,15 @@
## [Unreleased] ## [Unreleased]
### Breaking Changes
- Renamed `/branch` command to `/fork` ([#641](https://github.com/badlogic/pi-mono/issues/641))
- 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" | "tree"``"fork" | "tree"`
### Added ### Added
- `/models` command to enable/disable models for Ctrl+P cycling. Changes persist to `enabledModels` in settings.json and take effect immediately. ([#626](https://github.com/badlogic/pi-mono/pull/626) by [@CarlosGtrz](https://github.com/CarlosGtrz)) - `/models` command to enable/disable models for Ctrl+P cycling. Changes persist to `enabledModels` in settings.json and take effect immediately. ([#626](https://github.com/badlogic/pi-mono/pull/626) by [@CarlosGtrz](https://github.com/CarlosGtrz))

View file

@ -243,7 +243,7 @@ The agent reads, writes, and edits files, and executes commands via bash.
| `/hotkeys` | Show all keyboard shortcuts | | `/hotkeys` | Show all keyboard shortcuts |
| `/changelog` | Display full version history | | `/changelog` | Display full version history |
| `/tree` | Navigate session tree in-place (search, filter, label entries) | | `/tree` | Navigate session tree in-place (search, filter, label entries) |
| `/branch` | Create new conversation branch from a previous message | | `/fork` | Create new conversation fork from a previous message |
| `/resume` | Switch to a different session (interactive selector) | | `/resume` | Switch to a different session (interactive selector) |
| `/login` | OAuth login for subscription-based models | | `/login` | OAuth login for subscription-based models |
| `/logout` | Clear OAuth tokens | | `/logout` | Clear OAuth tokens |
@ -507,10 +507,10 @@ See [docs/compaction.md](docs/compaction.md) for how compaction works internally
- Press `l` to label entries as bookmarks - Press `l` to label entries as bookmarks
- When switching branches, you're prompted whether to generate a summary of the abandoned branch (messages up to the common ancestor) - When switching branches, you're prompted whether to generate a summary of the abandoned branch (messages up to the common ancestor)
**Create new session (`/branch`):** Branch to a new session file: **Create new session (`/fork`):** Fork to a new session file:
1. Opens selector showing all your user messages 1. Opens selector showing all your user messages
2. Select a message to branch from 2. Select a message to fork from
3. Creates new session with history up to that point 3. Creates new session with history up to that point
4. Selected message placed in editor for modification 4. Selected message placed in editor for modification
@ -859,7 +859,7 @@ Extensions are TypeScript modules that extend pi's behavior.
- **Custom tools** - Register tools callable by the LLM with custom UI and rendering - **Custom tools** - Register tools callable by the LLM with custom UI and rendering
- **Custom commands** - Add `/commands` for users (e.g., `/deploy`, `/stats`) - **Custom commands** - Add `/commands` for users (e.g., `/deploy`, `/stats`)
- **Event interception** - Block tool calls, modify results, customize compaction - **Event interception** - Block tool calls, modify results, customize compaction
- **State persistence** - Store data in session, reconstruct on reload/branch - **State persistence** - Store data in session, reconstruct on reload/fork
- **External integrations** - File watchers, webhooks, git checkpointing - **External integrations** - File watchers, webhooks, git checkpointing
- **Custom UI** - Full TUI control from tools, commands, or event handlers - **Custom UI** - Full TUI control from tools, commands, or event handlers
@ -1011,7 +1011,7 @@ export default function (pi: ExtensionAPI) {
}; };
pi.on("session_start", async (e, ctx) => reconstruct(ctx)); pi.on("session_start", async (e, ctx) => reconstruct(ctx));
pi.on("session_branch", async (e, ctx) => reconstruct(ctx)); pi.on("session_fork", async (e, ctx) => reconstruct(ctx));
pi.on("session_tree", async (e, ctx) => reconstruct(ctx)); pi.on("session_tree", async (e, ctx) => reconstruct(ctx));
pi.registerCommand("increment", { pi.registerCommand("increment", {

View file

@ -278,9 +278,9 @@ user sends another prompt ◄─────────────────
├─► session_before_switch (can cancel) ├─► session_before_switch (can cancel)
└─► session_switch └─► session_switch
/branch /fork
├─► session_before_branch (can cancel) ├─► session_before_fork (can cancel)
└─► session_branch └─► session_fork
/compact or auto-compaction /compact or auto-compaction
├─► session_before_compact (can cancel or customize) ├─► session_before_compact (can cancel or customize)
@ -334,19 +334,19 @@ pi.on("session_switch", async (event, ctx) => {
**Examples:** [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [status-line.ts](../examples/extensions/status-line.ts), [todo.ts](../examples/extensions/todo.ts) **Examples:** [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [status-line.ts](../examples/extensions/status-line.ts), [todo.ts](../examples/extensions/todo.ts)
#### session_before_branch / session_branch #### session_before_fork / session_fork
Fired when branching via `/branch`. Fired when forking via `/fork`.
```typescript ```typescript
pi.on("session_before_branch", async (event, ctx) => { pi.on("session_before_fork", async (event, ctx) => {
// event.entryId - ID of the entry being branched from // event.entryId - ID of the entry being forked from
return { cancel: true }; // Cancel branch return { cancel: true }; // Cancel fork
// OR // OR
return { skipConversationRestore: true }; // Branch but don't rewind messages return { skipConversationRestore: true }; // Fork but don't rewind messages
}); });
pi.on("session_branch", async (event, ctx) => { pi.on("session_fork", async (event, ctx) => {
// event.previousSessionFile - previous session file // event.previousSessionFile - previous session file
}); });
``` ```

View file

@ -541,47 +541,47 @@ If a hook cancelled the switch:
{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": true}} {"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": true}}
``` ```
#### branch #### fork
Create a new branch from a previous user message. Can be cancelled by a `before_branch` hook. Returns the text of the message being branched from. Create a new fork from a previous user message. Can be cancelled by a `before_fork` hook. Returns the text of the message being forked from.
```json ```json
{"type": "branch", "entryId": "abc123"} {"type": "fork", "entryId": "abc123"}
``` ```
Response: Response:
```json ```json
{ {
"type": "response", "type": "response",
"command": "branch", "command": "fork",
"success": true, "success": true,
"data": {"text": "The original prompt text...", "cancelled": false} "data": {"text": "The original prompt text...", "cancelled": false}
} }
``` ```
If a hook cancelled the branch: If a hook cancelled the fork:
```json ```json
{ {
"type": "response", "type": "response",
"command": "branch", "command": "fork",
"success": true, "success": true,
"data": {"text": "The original prompt text...", "cancelled": true} "data": {"text": "The original prompt text...", "cancelled": true}
} }
``` ```
#### get_branch_messages #### get_fork_messages
Get user messages available for branching. Get user messages available for forking.
```json ```json
{"type": "get_branch_messages"} {"type": "get_fork_messages"}
``` ```
Response: Response:
```json ```json
{ {
"type": "response", "type": "response",
"command": "get_branch_messages", "command": "get_fork_messages",
"success": true, "success": true,
"data": { "data": {
"messages": [ "messages": [

View file

@ -108,8 +108,8 @@ interface AgentSession {
newSession(options?: { parentSession?: string }): Promise<boolean>; // Returns false if cancelled by hook newSession(options?: { parentSession?: string }): Promise<boolean>; // Returns false if cancelled by hook
switchSession(sessionPath: string): Promise<boolean>; switchSession(sessionPath: string): Promise<boolean>;
// Branching // Forking
branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }>; // Creates new session file fork(entryId: string): Promise<{ selectedText: string; cancelled: boolean }>; // Creates new session file
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ editorText?: string; cancelled: boolean }>; // In-place navigation navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ editorText?: string; cancelled: boolean }>; // In-place navigation
// Hook message injection // Hook message injection

View file

@ -49,7 +49,7 @@ First line of the file. Metadata only, not part of the tree (no `id`/`parentId`)
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"} {"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}
``` ```
For sessions with a parent (created via `/branch` or `newSession({ parentSession })`): For sessions with a parent (created via `/fork` or `newSession({ parentSession })`):
```json ```json
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/original/session.jsonl"} {"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/original/session.jsonl"}

View file

@ -6,14 +6,14 @@ The `/tree` command provides tree-based navigation of the session history.
Sessions are stored as trees where each entry has an `id` and `parentId`. The "leaf" pointer tracks the current position. `/tree` lets you navigate to any point and optionally summarize the branch you're leaving. Sessions are stored as trees where each entry has an `id` and `parentId`. The "leaf" pointer tracks the current position. `/tree` lets you navigate to any point and optionally summarize the branch you're leaving.
### Comparison with `/branch` ### Comparison with `/fork`
| Feature | `/branch` | `/tree` | | Feature | `/fork` | `/tree` |
|---------|-----------|---------| |---------|---------|---------|
| View | Flat list of user messages | Full tree structure | | View | Flat list of user messages | Full tree structure |
| Action | Extracts path to **new session file** | Changes leaf in **same session** | | Action | Extracts path to **new session file** | Changes leaf in **same session** |
| Summary | Never | Optional (user prompted) | | Summary | Never | Optional (user prompted) |
| Events | `session_before_branch` / `session_branch` | `session_before_tree` / `session_tree` | | Events | `session_before_fork` / `session_fork` | `session_before_tree` / `session_tree` |
## Tree UI ## Tree UI

View file

@ -20,7 +20,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|-----------|-------------| |-----------|-------------|
| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) | | `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) |
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) | | `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, branch) | | `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) |
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes | | `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
### Custom Tools ### Custom Tools
@ -53,7 +53,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
| Extension | Description | | Extension | Description |
|-----------|-------------| |-----------|-------------|
| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch | | `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on fork |
| `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message | | `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message |
### System Prompt & Compaction ### System Prompt & Compaction
@ -129,7 +129,7 @@ action: Type.Union([Type.Literal("list"), Type.Literal("add")])
**State persistence via details:** **State persistence via details:**
```typescript ```typescript
// Store state in tool result details for proper branching support // Store state in tool result details for proper forking support
return { return {
content: [{ type: "text", text: "Done" }], content: [{ type: "text", text: "Done" }],
details: { todos: [...todos], nextId }, // Persisted in session details: { todos: [...todos], nextId }, // Persisted in session

View file

@ -43,16 +43,16 @@ export default function (pi: ExtensionAPI) {
} }
}); });
pi.on("session_before_branch", async (event, ctx) => { pi.on("session_before_fork", async (event, ctx) => {
if (!ctx.hasUI) return; if (!ctx.hasUI) return;
const choice = await ctx.ui.select(`Branch from entry ${event.entryId.slice(0, 8)}?`, [ const choice = await ctx.ui.select(`Fork from entry ${event.entryId.slice(0, 8)}?`, [
"Yes, create branch", "Yes, create fork",
"No, stay in current session", "No, stay in current session",
]); ]);
if (choice !== "Yes, create branch") { if (choice !== "Yes, create fork") {
ctx.ui.notify("Branch cancelled", "info"); ctx.ui.notify("Fork cancelled", "info");
return { cancel: true }; return { cancel: true };
} }
}); });

View file

@ -50,7 +50,7 @@ export default function (pi: ExtensionAPI) {
return checkDirtyRepo(pi, ctx, action); return checkDirtyRepo(pi, ctx, action);
}); });
pi.on("session_before_branch", async (_event, ctx) => { pi.on("session_before_fork", async (_event, ctx) => {
return checkDirtyRepo(pi, ctx, "branch"); return checkDirtyRepo(pi, ctx, "fork");
}); });
} }

View file

@ -1,8 +1,8 @@
/** /**
* Git Checkpoint Extension * Git Checkpoint Extension
* *
* Creates git stash checkpoints at each turn so /branch can restore code state. * Creates git stash checkpoints at each turn so /fork can restore code state.
* When branching, offers to restore code to that point in history. * When forking, offers to restore code to that point in history.
*/ */
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
@ -26,7 +26,7 @@ export default function (pi: ExtensionAPI) {
} }
}); });
pi.on("session_before_branch", async (event, ctx) => { pi.on("session_before_fork", async (event, ctx) => {
const ref = checkpoints.get(event.entryId); const ref = checkpoints.get(event.entryId);
if (!ref) return; if (!ref) return;

View file

@ -131,7 +131,7 @@ export default function (pi: ExtensionAPI) {
// Reconstruct state on session events // Reconstruct state on session events
pi.on("session_start", async (_event, ctx) => reconstructState(ctx)); pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
pi.on("session_switch", async (_event, ctx) => reconstructState(ctx)); pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
pi.on("session_branch", async (_event, ctx) => reconstructState(ctx)); pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx)); pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
// Register the todo tool for the LLM // Register the todo tool for the LLM

View file

@ -138,8 +138,8 @@ export default function toolsExtension(pi: ExtensionAPI) {
restoreFromBranch(ctx); restoreFromBranch(ctx);
}); });
// Restore state after branching // Restore state after forking
pi.on("session_branch", async (_event, ctx) => { pi.on("session_fork", async (_event, ctx) => {
restoreFromBranch(ctx); restoreFromBranch(ctx);
}); });
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -78,8 +78,8 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
} }
return { cancelled: !success }; return { cancelled: !success };
}, },
branch: async (entryId) => { fork: async (entryId) => {
const result = await session.branch(entryId); const result = await session.fork(entryId);
return { cancelled: result.cancelled }; return { cancelled: result.cancelled };
}, },
navigateTree: async (targetId, options) => { 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) * @returns Object with `text` (the message text) and `cancelled` (if extension cancelled)
*/ */
async branch(entryId: string): Promise<{ text: string; cancelled: boolean }> { async fork(entryId: string): Promise<{ text: string; cancelled: boolean }> {
const response = await this.send({ type: "branch", entryId }); const response = await this.send({ type: "fork", entryId });
return this.getData(response); return this.getData(response);
} }
/** /**
* Get messages available for branching. * Get messages available for forking.
*/ */
async getBranchMessages(): Promise<Array<{ entryId: string; text: string }>> { async getForkMessages(): Promise<Array<{ entryId: string; text: string }>> {
const response = await this.send({ type: "get_branch_messages" }); const response = await this.send({ type: "get_fork_messages" });
return this.getData<{ messages: Array<{ entryId: string; text: string }> }>(response).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 }; return { cancelled: !success };
}, },
branch: async (entryId) => { fork: async (entryId) => {
const result = await session.branch(entryId); const result = await session.fork(entryId);
return { cancelled: result.cancelled }; return { cancelled: result.cancelled };
}, },
navigateTree: async (targetId, options) => { navigateTree: async (targetId, options) => {
@ -508,14 +508,14 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return success(id, "switch_session", { cancelled }); return success(id, "switch_session", { cancelled });
} }
case "branch": { case "fork": {
const result = await session.branch(command.entryId); const result = await session.fork(command.entryId);
return success(id, "branch", { text: result.selectedText, cancelled: result.cancelled }); return success(id, "fork", { text: result.selectedText, cancelled: result.cancelled });
} }
case "get_branch_messages": { case "get_fork_messages": {
const messages = session.getUserMessagesForBranching(); const messages = session.getUserMessagesForForking();
return success(id, "get_branch_messages", { messages }); return success(id, "get_fork_messages", { messages });
} }
case "get_last_assistant_text": { case "get_last_assistant_text": {

View file

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

View file

@ -1,10 +1,10 @@
/** /**
* Tests for AgentSession branching behavior. * Tests for AgentSession forking behavior.
* *
* These tests verify: * These tests verify:
* - Branching from a single message works * - Forking from a single message works
* - Branching in --no-session mode (in-memory only) * - Forking in --no-session mode (in-memory only)
* - getUserMessagesForBranching returns correct entries * - getUserMessagesForForking returns correct entries
*/ */
import { existsSync, mkdirSync, rmSync } from "node:fs"; import { existsSync, mkdirSync, rmSync } from "node:fs";
@ -21,7 +21,7 @@ import { SettingsManager } from "../src/core/settings-manager.js";
import { codingTools } from "../src/core/tools/index.js"; import { codingTools } from "../src/core/tools/index.js";
import { API_KEY } from "./utilities.js"; import { API_KEY } from "./utilities.js";
describe.skipIf(!API_KEY)("AgentSession branching", () => { describe.skipIf(!API_KEY)("AgentSession forking", () => {
let session: AgentSession; let session: AgentSession;
let tempDir: string; let tempDir: string;
let sessionManager: SessionManager; let sessionManager: SessionManager;
@ -70,32 +70,32 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
return session; return session;
} }
it("should allow branching from single message", async () => { it("should allow forking from single message", async () => {
createSession(); createSession();
// Send one message // Send one message
await session.prompt("Say hello"); await session.prompt("Say hello");
await session.agent.waitForIdle(); await session.agent.waitForIdle();
// Should have exactly 1 user message available for branching // Should have exactly 1 user message available for forking
const userMessages = session.getUserMessagesForBranching(); const userMessages = session.getUserMessagesForForking();
expect(userMessages.length).toBe(1); expect(userMessages.length).toBe(1);
expect(userMessages[0].text).toBe("Say hello"); expect(userMessages[0].text).toBe("Say hello");
// Branch from the first message // Fork from the first message
const result = await session.branch(userMessages[0].entryId); const result = await session.fork(userMessages[0].entryId);
expect(result.selectedText).toBe("Say hello"); expect(result.selectedText).toBe("Say hello");
expect(result.cancelled).toBe(false); expect(result.cancelled).toBe(false);
// After branching, conversation should be empty (branched before the first message) // After forking, conversation should be empty (forked before the first message)
expect(session.messages.length).toBe(0); expect(session.messages.length).toBe(0);
// Session file should exist (new branch) // Session file should exist (new fork)
expect(session.sessionFile).not.toBeNull(); expect(session.sessionFile).not.toBeNull();
expect(existsSync(session.sessionFile!)).toBe(true); expect(existsSync(session.sessionFile!)).toBe(true);
}); });
it("should support in-memory branching in --no-session mode", async () => { it("should support in-memory forking in --no-session mode", async () => {
createSession(true); createSession(true);
// Verify sessions are disabled // Verify sessions are disabled
@ -106,25 +106,25 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
await session.agent.waitForIdle(); await session.agent.waitForIdle();
// Should have 1 user message // Should have 1 user message
const userMessages = session.getUserMessagesForBranching(); const userMessages = session.getUserMessagesForForking();
expect(userMessages.length).toBe(1); expect(userMessages.length).toBe(1);
// Verify we have messages before branching // Verify we have messages before forking
expect(session.messages.length).toBeGreaterThan(0); expect(session.messages.length).toBeGreaterThan(0);
// Branch from the first message // Fork from the first message
const result = await session.branch(userMessages[0].entryId); const result = await session.fork(userMessages[0].entryId);
expect(result.selectedText).toBe("Say hi"); expect(result.selectedText).toBe("Say hi");
expect(result.cancelled).toBe(false); expect(result.cancelled).toBe(false);
// After branching, conversation should be empty // After forking, conversation should be empty
expect(session.messages.length).toBe(0); expect(session.messages.length).toBe(0);
// Session file should still be undefined (no file created) // Session file should still be undefined (no file created)
expect(session.sessionFile).toBeUndefined(); expect(session.sessionFile).toBeUndefined();
}); });
it("should branch from middle of conversation", async () => { it("should fork from middle of conversation", async () => {
createSession(); createSession();
// Send multiple messages // Send multiple messages
@ -138,15 +138,15 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
await session.agent.waitForIdle(); await session.agent.waitForIdle();
// Should have 3 user messages // Should have 3 user messages
const userMessages = session.getUserMessagesForBranching(); const userMessages = session.getUserMessagesForForking();
expect(userMessages.length).toBe(3); expect(userMessages.length).toBe(3);
// Branch from second message (keeps first message + response) // Fork from second message (keeps first message + response)
const secondMessage = userMessages[1]; const secondMessage = userMessages[1];
const result = await session.branch(secondMessage.entryId); const result = await session.fork(secondMessage.entryId);
expect(result.selectedText).toBe("Say two"); expect(result.selectedText).toBe("Say two");
// After branching, should have first user message + assistant response // After forking, should have first user message + assistant response
expect(session.messages.length).toBe(2); expect(session.messages.length).toBe(2);
expect(session.messages[0].role).toBe("user"); expect(session.messages[0].role).toBe("user");
expect(session.messages[1].role).toBe("assistant"); expect(session.messages[1].role).toBe("assistant");