Add /branch command for conversation branching (fixes #16)

- Add /branch slash command to create conversation branches
- New UserMessageSelectorComponent shows all user messages chronologically
- Selecting a message creates new session with messages before selection
- Selected message is placed in editor for modification/resubmission
- SessionManager.createBranchedSession() creates new session files
- Updated README.md and CHANGELOG.md with /branch documentation
This commit is contained in:
Mario Zechner 2025-11-14 23:52:46 +01:00
parent 85ea9f500c
commit 8ae236f956
6 changed files with 379 additions and 66 deletions

View file

@ -4779,23 +4779,6 @@ export const MODELS = {
contextWindow: 200000,
maxTokens: 8192,
} satisfies Model<"openai-completions">,
"mistralai/ministral-3b": {
id: "mistralai/ministral-3b",
name: "Mistral: Ministral 3B",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.04,
output: 0.04,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"mistralai/ministral-8b": {
id: "mistralai/ministral-8b",
name: "Mistral: Ministral 8B",
@ -4813,6 +4796,23 @@ export const MODELS = {
contextWindow: 131072,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"mistralai/ministral-3b": {
id: "mistralai/ministral-3b",
name: "Mistral: Ministral 3B",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.04,
output: 0.04,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"qwen/qwen-2.5-7b-instruct": {
id: "qwen/qwen-2.5-7b-instruct",
name: "Qwen: Qwen2.5 7B Instruct",
@ -5085,9 +5085,9 @@ export const MODELS = {
contextWindow: 131072,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"openai/gpt-4o-mini": {
id: "openai/gpt-4o-mini",
name: "OpenAI: GPT-4o-mini",
"openai/gpt-4o-mini-2024-07-18": {
id: "openai/gpt-4o-mini-2024-07-18",
name: "OpenAI: GPT-4o-mini (2024-07-18)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
@ -5102,9 +5102,9 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"openai/gpt-4o-mini-2024-07-18": {
id: "openai/gpt-4o-mini-2024-07-18",
name: "OpenAI: GPT-4o-mini (2024-07-18)",
"openai/gpt-4o-mini": {
id: "openai/gpt-4o-mini",
name: "OpenAI: GPT-4o-mini",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
@ -5221,6 +5221,23 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-4o-2024-05-13": {
id: "openai/gpt-4o-2024-05-13",
name: "OpenAI: GPT-4o (2024-05-13)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 5,
output: 15,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-4o": {
id: "openai/gpt-4o",
name: "OpenAI: GPT-4o",
@ -5255,22 +5272,22 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 64000,
} satisfies Model<"openai-completions">,
"openai/gpt-4o-2024-05-13": {
id: "openai/gpt-4o-2024-05-13",
name: "OpenAI: GPT-4o (2024-05-13)",
"meta-llama/llama-3-70b-instruct": {
id: "meta-llama/llama-3-70b-instruct",
name: "Meta: Llama 3 70B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text", "image"],
input: ["text"],
cost: {
input: 5,
output: 15,
input: 0.3,
output: 0.39999999999999997,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4096,
contextWindow: 8192,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3-8b-instruct": {
id: "meta-llama/llama-3-8b-instruct",
@ -5289,23 +5306,6 @@ export const MODELS = {
contextWindow: 8192,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3-70b-instruct": {
id: "meta-llama/llama-3-70b-instruct",
name: "Meta: Llama 3 70B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.3,
output: 0.39999999999999997,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 8192,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"mistralai/mixtral-8x22b-instruct": {
id: "mistralai/mixtral-8x22b-instruct",
name: "Mistral: Mixtral 8x22B Instruct",
@ -5544,23 +5544,6 @@ export const MODELS = {
contextWindow: 8191,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-3.5-turbo": {
id: "openai/gpt-3.5-turbo",
name: "OpenAI: GPT-3.5 Turbo",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.5,
output: 1.5,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 16385,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-4": {
id: "openai/gpt-4",
name: "OpenAI: GPT-4",
@ -5578,6 +5561,23 @@ export const MODELS = {
contextWindow: 8191,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-3.5-turbo": {
id: "openai/gpt-3.5-turbo",
name: "OpenAI: GPT-3.5 Turbo",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.5,
output: 1.5,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 16385,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openrouter/auto": {
id: "openrouter/auto",
name: "OpenRouter: Auto Router",

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- `/branch` command for creating conversation branches. Opens a selector showing all user messages in chronological order. Selecting a message creates a new session with all messages before the selected one, and places the selected message in the editor for modification or resubmission. This allows exploring alternative conversation paths without losing the current session. (fixes [#16](https://github.com/badlogic/pi-mono/issues/16))
## [0.7.9] - 2025-11-14
### Changed

View file

@ -107,6 +107,18 @@ Display the full changelog with all version history (newest last):
/changelog
```
### /branch
Create a new conversation branch from a previous message. Opens an interactive selector showing all your user messages in chronological order. Select a message to:
1. Create a new session with all messages before the selected one
2. Place the selected message in the editor for modification or resubmission
This allows you to explore alternative conversation paths without losing your current session.
```
/branch
```
## Editor Features
The interactive input editor includes several productivity features:

View file

@ -402,4 +402,43 @@ export class SessionManager {
return userMessages.length >= 1 && assistantMessages.length >= 1;
}
/**
* Create a branched session from a specific message index.
* If branchFromIndex is -1, creates an empty session.
* Returns the new session file path.
*/
createBranchedSession(state: any, branchFromIndex: number): string {
// Create a new session ID for the branch
const newSessionId = uuidv4();
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`);
// Write session header
const entry: SessionHeader = {
type: "session",
id: newSessionId,
timestamp: new Date().toISOString(),
cwd: process.cwd(),
provider: state.model.provider,
modelId: state.model.id,
thinkingLevel: state.thinkingLevel,
};
appendFileSync(newSessionFile, JSON.stringify(entry) + "\n");
// Write messages up to and including the branch point (if >= 0)
if (branchFromIndex >= 0) {
const messagesToWrite = state.messages.slice(0, branchFromIndex + 1);
for (const message of messagesToWrite) {
const messageEntry: SessionMessageEntry = {
type: "message",
timestamp: new Date().toISOString(),
message,
};
appendFileSync(newSessionFile, JSON.stringify(messageEntry) + "\n");
}
}
return newSessionFile;
}
}

View file

@ -23,6 +23,7 @@ import { ModelSelectorComponent } from "./model-selector.js";
import { ThinkingSelectorComponent } from "./thinking-selector.js";
import { ToolExecutionComponent } from "./tool-execution.js";
import { UserMessageComponent } from "./user-message.js";
import { UserMessageSelectorComponent } from "./user-message-selector.js";
/**
* TUI renderer for the coding agent
@ -56,6 +57,9 @@ export class TuiRenderer {
// Model selector
private modelSelector: ModelSelectorComponent | null = null;
// User message selector (for branching)
private userMessageSelector: UserMessageSelectorComponent | null = null;
// Track if this is the first user message (to skip spacer)
private isFirstUserMessage = true;
@ -98,9 +102,14 @@ export class TuiRenderer {
description: "Show changelog entries",
};
const branchCommand: SlashCommand = {
name: "branch",
description: "Create a new branch from a previous message",
};
// Setup autocomplete for file paths and slash commands
const autocompleteProvider = new CombinedAutocompleteProvider(
[thinkingCommand, modelCommand, exportCommand, sessionCommand, changelogCommand],
[thinkingCommand, modelCommand, exportCommand, sessionCommand, changelogCommand, branchCommand],
process.cwd(),
);
this.editor.setAutocompleteProvider(autocompleteProvider);
@ -207,6 +216,13 @@ export class TuiRenderer {
return;
}
// Check for /branch command
if (text === "/branch") {
this.showUserMessageSelector();
this.editor.setText("");
return;
}
if (this.onInputCallback) {
this.onInputCallback(text);
}
@ -566,6 +582,90 @@ export class TuiRenderer {
this.ui.setFocus(this.editor);
}
private showUserMessageSelector(): void {
// Extract all user messages from the current state
const userMessages: Array<{ index: number; text: string }> = [];
for (let i = 0; i < this.agent.state.messages.length; i++) {
const message = this.agent.state.messages[i];
if (message.role === "user") {
const userMsg = message as any;
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
const textContent = textBlocks.map((c: any) => c.text).join("");
if (textContent) {
userMessages.push({ index: i, text: textContent });
}
}
}
// Don't show selector if there are no messages or only one message
if (userMessages.length <= 1) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.dim("No messages to branch from"), 1, 0));
this.ui.requestRender();
return;
}
// Create user message selector
this.userMessageSelector = new UserMessageSelectorComponent(
userMessages,
(messageIndex) => {
// Get the selected user message text to put in the editor
const selectedMessage = this.agent.state.messages[messageIndex];
const selectedUserMsg = selectedMessage as any;
const textBlocks = selectedUserMsg.content.filter((c: any) => c.type === "text");
const selectedText = textBlocks.map((c: any) => c.text).join("");
// Create a branched session with messages UP TO (but not including) the selected message
const newSessionFile = this.sessionManager.createBranchedSession(this.agent.state, messageIndex - 1);
// Set the new session file as active
this.sessionManager.setSessionFile(newSessionFile);
// Truncate messages in agent state to before the selected message
const truncatedMessages = this.agent.state.messages.slice(0, messageIndex);
this.agent.replaceMessages(truncatedMessages);
// Clear and re-render the chat
this.chatContainer.clear();
this.isFirstUserMessage = true;
this.renderInitialMessages(this.agent.state);
// Show confirmation message
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Text(chalk.dim(`Branched to new session from message ${messageIndex}`), 1, 0),
);
// Put the selected message in the editor
this.editor.setText(selectedText);
// Hide selector and show editor again
this.hideUserMessageSelector();
this.ui.requestRender();
},
() => {
// Just hide the selector
this.hideUserMessageSelector();
this.ui.requestRender();
},
);
// Replace editor with selector
this.editorContainer.clear();
this.editorContainer.addChild(this.userMessageSelector);
this.ui.setFocus(this.userMessageSelector.getMessageList());
this.ui.requestRender();
}
private hideUserMessageSelector(): void {
// Replace selector with editor in the container
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.userMessageSelector = null;
this.ui.setFocus(this.editor);
}
private handleExportCommand(text: string): void {
// Parse optional filename from command: /export [filename]
const parts = text.split(/\s+/);

View file

@ -0,0 +1,158 @@
import { type Component, Container, Spacer, Text } from "@mariozechner/pi-tui";
import chalk from "chalk";
/**
* Dynamic border component that adjusts to viewport width
*/
class DynamicBorder implements Component {
private colorFn: (text: string) => string;
constructor(colorFn: (text: string) => string = chalk.blue) {
this.colorFn = colorFn;
}
render(width: number): string[] {
return [this.colorFn("─".repeat(Math.max(1, width)))];
}
}
interface UserMessageItem {
index: number; // Index in the full messages array
text: string; // The message text
timestamp?: string; // Optional timestamp if available
}
/**
* Custom user message list component with selection
*/
class UserMessageList implements Component {
private messages: UserMessageItem[] = [];
private selectedIndex: number = 0;
public onSelect?: (messageIndex: number) => void;
public onCancel?: () => void;
private maxVisible: number = 10; // Max messages visible
constructor(messages: UserMessageItem[]) {
// Store messages in chronological order (oldest to newest)
this.messages = messages;
// Start with the last (most recent) message selected
this.selectedIndex = Math.max(0, messages.length - 1);
}
render(width: number): string[] {
const lines: string[] = [];
if (this.messages.length === 0) {
lines.push(chalk.gray(" No user messages found"));
return lines;
}
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.messages.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.messages.length);
// Render visible messages (2 lines per message + blank line)
for (let i = startIndex; i < endIndex; i++) {
const message = this.messages[i];
const isSelected = i === this.selectedIndex;
// Normalize message to single line
const normalizedMessage = message.text.replace(/\n/g, " ").trim();
// First line: cursor + message
const cursor = isSelected ? chalk.blue(" ") : " ";
const maxMsgWidth = width - 2; // Account for cursor
const truncatedMsg = normalizedMessage.substring(0, maxMsgWidth);
const messageLine = cursor + (isSelected ? chalk.bold(truncatedMsg) : truncatedMsg);
lines.push(messageLine);
// Second line: metadata (position in history)
const position = i + 1;
const metadata = ` Message ${position} of ${this.messages.length}`;
const metadataLine = chalk.dim(metadata);
lines.push(metadataLine);
lines.push(""); // Blank line between messages
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < this.messages.length) {
const scrollInfo = chalk.gray(` (${this.selectedIndex + 1}/${this.messages.length})`);
lines.push(scrollInfo);
}
return lines;
}
handleInput(keyData: string): void {
// Up arrow - go to previous (older) message
if (keyData === "\x1b[A") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
}
// Down arrow - go to next (newer) message
else if (keyData === "\x1b[B") {
this.selectedIndex = Math.min(this.messages.length - 1, this.selectedIndex + 1);
}
// Enter - select message and branch
else if (keyData === "\r") {
const selected = this.messages[this.selectedIndex];
if (selected && this.onSelect) {
this.onSelect(selected.index);
}
}
// Escape - cancel
else if (keyData === "\x1b") {
if (this.onCancel) {
this.onCancel();
}
}
// Ctrl+C - cancel
else if (keyData === "\x03") {
if (this.onCancel) {
this.onCancel();
}
}
}
}
/**
* Component that renders a user message selector for branching
*/
export class UserMessageSelectorComponent extends Container {
private messageList: UserMessageList;
constructor(messages: UserMessageItem[], onSelect: (messageIndex: number) => void, onCancel: () => void) {
super();
// Add header
this.addChild(new Spacer(1));
this.addChild(new Text(chalk.bold("Branch from Message"), 1, 0));
this.addChild(new Text(chalk.dim("Select a message to create a new branch from that point"), 1, 0));
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Create message list
this.messageList = new UserMessageList(messages);
this.messageList.onSelect = onSelect;
this.messageList.onCancel = onCancel;
this.addChild(this.messageList);
// Add bottom border
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
// Auto-cancel if no messages or only one message
if (messages.length <= 1) {
setTimeout(() => onCancel(), 100);
}
}
getMessageList(): UserMessageList {
return this.messageList;
}
}