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

@ -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;
}
}