diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index c6a99c4e..f5050910 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -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", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a3581579..a83ed921 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 1147540b..cdebfd7c 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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: diff --git a/packages/coding-agent/src/session-manager.ts b/packages/coding-agent/src/session-manager.ts index f5490580..606dbb75 100644 --- a/packages/coding-agent/src/session-manager.ts +++ b/packages/coding-agent/src/session-manager.ts @@ -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; + } } diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index bd7c3803..794c64c6 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -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+/); diff --git a/packages/coding-agent/src/tui/user-message-selector.ts b/packages/coding-agent/src/tui/user-message-selector.ts new file mode 100644 index 00000000..1482984a --- /dev/null +++ b/packages/coding-agent/src/tui/user-message-selector.ts @@ -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; + } +}