From 4958271dd30faebe58dab2f98be4637aa7eea1bc Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Dec 2025 02:29:35 +0100 Subject: [PATCH] feat(coding-agent): implement /tree command for session tree navigation - Add TreeSelectorComponent with ASCII tree visualization - Add AgentSession.navigateTree() for switching branches - Add session_before_tree/session_tree hook events - Add SessionManager.resetLeaf() for navigating to root - Change leafId from string to string|null for consistency with parentId - Support optional branch summarization when switching - Update buildSessionContext() to handle null leafId - Add /tree to slash commands in interactive mode --- packages/ai/src/models.generated.ts | 114 ++--- packages/coding-agent/docs/tree.md | 452 ++++-------------- .../coding-agent/src/core/agent-session.ts | 259 +++++++++- .../src/core/custom-tools/types.ts | 2 +- .../coding-agent/src/core/hooks/runner.ts | 19 +- packages/coding-agent/src/core/hooks/types.ts | 60 ++- .../coding-agent/src/core/session-manager.ts | 51 +- .../interactive/components/tree-selector.ts | 315 ++++++++++++ .../src/modes/interactive/interactive-mode.ts | 64 +++ 9 files changed, 893 insertions(+), 443 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/tree-selector.ts diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 2df269d3..6392308c 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6104,9 +6104,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6121,9 +6121,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6359,23 +6359,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -6410,6 +6393,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -6546,23 +6546,6 @@ 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", @@ -6597,6 +6580,23 @@ 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)", + 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">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -6835,23 +6835,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-4": { - id: "openai/gpt-4", - name: "OpenAI: GPT-4", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 30, - output: 60, - cacheRead: 0, - cacheWrite: 0, - }, - 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", @@ -6869,6 +6852,23 @@ export const MODELS = { contextWindow: 16385, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4": { + id: "openai/gpt-4", + name: "OpenAI: GPT-4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 30, + output: 60, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 8191, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/coding-agent/docs/tree.md b/packages/coding-agent/docs/tree.md index d44e5d56..768a1cc0 100644 --- a/packages/coding-agent/docs/tree.md +++ b/packages/coding-agent/docs/tree.md @@ -1,445 +1,182 @@ -# Branch Summary +# Session Tree Navigation -This document describes the `/tree` command and branch summarization feature. +The `/tree` command provides tree-based navigation of the session history. ## Overview -The `/tree` command provides tree-based navigation of the session history, allowing users to: -1. View the entire session tree structure -2. Switch to any branch point -3. Optionally summarize the branch being abandoned +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. -This differs from `/branch` which extracts a linear path to a new session file. +### Comparison with `/branch` -## Commands - -### `/branch` (existing) -- Shows a flat list of user messages -- Extracts selected path to a **new session file** -- Selected user message text goes to editor for re-submission -- Fires `session_before_branch` / `session_branch` events - -### `/tree` (new) -- Shows the **full session tree** with visual hierarchy -- Navigates within the **same session file** (changes active leaf) -- Optionally summarizes the abandoned branch -- Fires `session_before_tree` / `session_tree` events +| Feature | `/branch` | `/tree` | +|---------|-----------|---------| +| View | Flat list of user messages | Full tree structure | +| Action | Extracts path to **new session file** | Changes leaf in **same session** | +| Summary | Never | Optional (user prompted) | +| Events | `session_before_branch` / `session_branch` | `session_before_tree` / `session_tree` | ## Tree UI -The tree selector displays the session structure with ASCII art: - ``` ├─ user: "Hello, can you help..." │ └─ assistant: "Of course! I can..." │ ├─ user: "Let's try approach A..." │ │ └─ assistant: "For approach A..." │ │ └─ [compaction: 12k tokens] -│ │ └─ user: "That worked, now..." -│ │ └─ assistant: "Great! Next..." ← active +│ │ └─ user: "That worked..." ← active │ └─ user: "Actually, approach B..." │ └─ assistant: "For approach B..." ``` -### Visual Indicators - -| Element | Display | -|---------|---------| -| Current active leaf | `← active` suffix, highlighted | -| User messages | Normal color (selectable) | -| Custom messages (display: true) | Normal color (selectable) | -| Assistant/tool results | Dimmed (selectable, for context continuation) | -| Compaction nodes | `[compaction: Xk tokens]` | -| Branch points | Node with multiple children visible | - -### Navigation +### Controls | Key | Action | |-----|--------| -| ↑/↓ | Move through nodes (depth-first pre-order) | -| Enter | Select node and proceed | -| Escape | Cancel | -| Ctrl+C | Cancel | -| Ctrl+U | Toggle: show only user messages | -| Ctrl+O | Toggle: show all entries (including custom/label) | +| ↑/↓ | Navigate (depth-first order) | +| Enter | Select node | +| Escape/Ctrl+C | Cancel | +| Ctrl+U | Toggle: user messages only | +| Ctrl+O | Toggle: show all (including custom/label entries) | -### Filtering +### Display -Default view hides: -- `label` entries (labels shown inline on their target node) -- `custom` entries (hook state, not relevant for navigation) - -Ctrl+O shows everything for debugging/inspection. - -### Component Size - -Height is capped at **half terminal height** to show substantial tree context without overshooting the terminal. +- Height: half terminal height +- Current leaf marked with `← active` +- Labels shown inline: `[label-name]` +- Default filter hides `label` and `custom` entries (shown in Ctrl+O mode) +- Children sorted by timestamp (oldest first) ## Selection Behavior -### Selecting Current Active Leaf +### User Message or Custom Message +1. Leaf set to **parent** of selected node (or `null` if root) +2. Message text placed in **editor** for re-submission +3. User edits and submits, creating a new branch -No-op. Display message: "Already at this point." +### Non-User Message (assistant, compaction, etc.) +1. Leaf set to **selected node** +2. Editor stays empty +3. User continues from that point -### Switching to Different Node - -### User Message or Custom Message Selected -1. Active leaf is set to **parent** of selected node -2. Selected message text is placed in the **editor** for re-submission -3. User edits and submits, creating a new branch from that point - -### Non-User Message Selected (assistant, tool result, etc.) -1. Active leaf is set to the **selected node itself** -2. Editor remains empty -3. User continues the conversation from that point +### Selecting Root User Message +If user selects the very first message (has no parent): +1. Leaf reset to `null` (empty conversation) +2. Message text placed in editor +3. User effectively restarts from scratch ## Branch Summarization -When switching branches, the user is prompted: "Summarize the branch you're leaving?" +When switching, user is prompted: "Summarize the branch you're leaving?" ### What Gets Summarized -The abandoned branch is the path from the **old active leaf** back to the **common ancestor** of the old leaf and newly selected node. +Path from old leaf back to common ancestor with target: ``` -A → B → C → D → E → F ← old active leaf - ↘ G → H ← user selects H +A → B → C → D → E → F ← old leaf + ↘ G → H ← target ``` -- Common ancestor: C -- Abandoned path: D → E → F -- These nodes are summarized +Abandoned path: D → E → F (summarized) -### Stopping Conditions - -When walking back from the old leaf to gather content for summarization: - -1. **Stop at common ancestor** (always) -2. **Stop at compaction node** (if encountered before common ancestor) - - Compaction already summarizes older content - - Only summarize "fresh" content after the compaction +Summarization stops at: +1. Common ancestor (always) +2. Compaction node (if encountered first) ### Summary Storage -The summary is stored as a `BranchSummaryEntry`: +Stored as `BranchSummaryEntry`: ```typescript interface BranchSummaryEntry { type: "branch_summary"; id: string; - parentId: string; // Points to common ancestor + parentId: string; // New leaf position timestamp: string; - fromId: string; // The old leaf we abandoned + fromId: string; // Old leaf we abandoned summary: string; // LLM-generated summary details?: unknown; // Optional hook data } ``` -The summary entry becomes a sibling of the path we're switching to, preserving the record of what was abandoned. +## Implementation -### Summary Generation - -The summarizer: -1. Collects messages from old leaf back to stopping point -2. Sends to LLM with prompt: "Summarize this conversation branch concisely" -3. Creates `BranchSummaryEntry` with the result - -User can skip summarization, in which case no `BranchSummaryEntry` is created. - -## Example Flow - -``` -Initial state: -A → B → C → D ← active - -User runs /tree, selects B: - -1. Show tree: - ├─ A (user): "Start task..." - │ └─ B (assistant): "I'll help..." - │ └─ C (user): "Do X..." - │ └─ D (assistant): "Done X..." ← active - -2. User navigates to B, presses Enter - -3. Prompt: "Summarize branch you're leaving? [Y/n]" - -4a. If Yes: - - Summarize C → D - - Create BranchSummaryEntry(fromId: D, summary: "...") - - Set active leaf to B - - Tree becomes: - A → B → C → D - ↓ ↘ [summary: "Tried X..."] - └─ (active, user continues from here) - -4b. If No: - - Set active leaf to B - - No summary entry created - -5. Since B is assistant message: - - Editor stays empty - - User types new message, branches from B -``` - -## Implementation Notes - -### SessionManager Methods (already exist) - -- `getTree()` - Get full tree structure for display (needs: sort children by timestamp) -- `getPath(id)` - Get path from root to any node -- `getEntry(id)` - Look up individual entries -- `getLeafUuid()` - Get current active leaf -- `branch(id)` - Change active leaf -- `branchWithSummary(fromId, summary)` - Create branch summary entry -- `buildSessionContext()` - Get messages for LLM from current leaf - -### AgentSession: New `navigateTree()` Method +### AgentSession.navigateTree() ```typescript -interface NavigateTreeOptions { - /** Whether user wants to summarize abandoned branch */ - summarize?: boolean; - /** Custom instructions for summarizer */ - customInstructions?: string; -} - -interface NavigateTreeResult { - /** Text to put in editor (if user message selected) */ - editorText?: string; - /** Whether navigation was cancelled */ - cancelled: boolean; -} - -async navigateTree(targetId: string, options?: NavigateTreeOptions): Promise +async navigateTree( + targetId: string, + options?: { summarize?: boolean; customInstructions?: string } +): Promise<{ editorText?: string; cancelled: boolean }> ``` -Implementation flow: +Flow: +1. Validate target, check no-op (target === current leaf) +2. Find common ancestor between old leaf and target +3. Collect entries to summarize (if requested) +4. Fire `session_before_tree` event (hook can cancel or provide summary) +5. Run default summarizer if needed +6. Switch leaf via `branch()` or `branchWithSummary()` +7. Update agent: `agent.replaceMessages(sessionManager.buildSessionContext().messages)` +8. Fire `session_tree` event +9. Notify custom tools via session event +10. Return result with `editorText` if user message was selected -1. **Validate target exists** -2. **Check if no-op** (target === current leaf) → return early -3. **Prepare summarization** (if `options.summarize`): - - Find common ancestor - - Collect entries to summarize (old leaf → common ancestor, stop at compaction) -4. **Fire `session_before_tree` event**: - - Pass preparation, model, signal - - If hook returns `cancel: true` → return `{ cancelled: true }` - - If hook returns custom summary → use it, skip default summarizer -5. **Run default summarizer** (if needed): - - Use conversation model - - On failure/abort → return `{ cancelled: true }` -6. **Switch leaf**: - - If summarizing: `sessionManager.branchWithSummary(targetId, summary)` - - Otherwise: `sessionManager.branch(targetId)` -7. **Update agent state**: - ```typescript - const context = this.sessionManager.buildSessionContext(); - this.agent.replaceMessages(context.messages); - ``` -8. **Fire `session_tree` event** -9. **Notify custom tools** via `_emitToolSessionEvent("tree", ...)` -10. **Return result**: - - If target was user message: `{ editorText: messageText, cancelled: false }` - - Otherwise: `{ cancelled: false }` +### SessionManager -### InteractiveMode: `/tree` Command Handler +- `getLeafUuid(): string | null` - Current leaf (null if empty) +- `resetLeaf(): void` - Set leaf to null (for root user message navigation) +- `getTree(): SessionTreeNode[]` - Full tree with children sorted by timestamp +- `branch(id)` - Change leaf pointer +- `branchWithSummary(id, summary)` - Change leaf and create summary entry -```typescript -if (text === "/tree") { - this.showTreeSelector(); - this.editor.setText(""); - return; -} -``` +### InteractiveMode -`showTreeSelector()` flow: - -1. Get tree via `sessionManager.getTree()` -2. Show `TreeSelectorComponent` (new component) -3. On selection: - - If target === current leaf → show "Already at this point", done - - Prompt: "Summarize branch you're leaving? [Y/n]" - - Call `session.navigateTree(targetId, { summarize })` - - If cancelled → done - - Clear chat: `this.chatContainer.clear()` - - Re-render: `this.renderInitialMessages()` - - If `result.editorText` → `this.editor.setText(result.editorText)` - - Show status: "Switched to entry X" - -### TUI Update Flow - -After `navigateTree()` completes successfully: - -```typescript -// In InteractiveMode, after navigateTree returns -if (!result.cancelled) { - this.chatContainer.clear(); - this.renderInitialMessages(); // Uses sessionManager.buildSessionContext() - if (result.editorText) { - this.editor.setText(result.editorText); - } - this.showStatus("Navigated to selected point"); -} -``` - -This matches the existing pattern in `handleResumeSession()` and `handleClearCommand()`. - -### Finding Common Ancestor - -```typescript -function findCommonAncestor(nodeA: string, nodeB: string): string { - const pathA = new Set(sessionManager.getPath(nodeA).map(e => e.id)); - for (const entry of sessionManager.getPath(nodeB)) { - if (pathA.has(entry.id)) { - return entry.id; - } - } - throw new Error("No common ancestor found"); -} -``` - -### Collecting Abandoned Branch - -```typescript -function collectAbandonedBranch(oldLeaf: string, commonAncestor: string): SessionEntry[] { - const entries: SessionEntry[] = []; - let current = oldLeaf; - - while (current !== commonAncestor) { - const entry = sessionManager.getEntry(current); - if (!entry) break; - - // Stop at compaction - older content already summarized - if (entry.type === "compaction") break; - - entries.push(entry); - current = entry.parentId; - } - - return entries.reverse(); // Chronological order -} -``` - -### Tree Child Ordering - -`getTree()` should sort children by timestamp (oldest first, newest at bottom): - -```typescript -// In getTree(), after building tree: -function sortChildren(node: SessionTreeNode): void { - node.children.sort((a, b) => - new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime() - ); - node.children.forEach(sortChildren); -} -roots.forEach(sortChildren); -``` - -### Error Handling - -**Summarization fails** (API error, timeout, etc.): -- Cancel the entire switch -- Show error message -- User stays at current position - -**User aborts during summarization** (Escape): -- Cancel the entire switch -- Show "Navigation cancelled" -- User stays at current position - -**Hook returns `cancel: true`**: -- Cancel the switch -- No error message (hook may have shown its own UI) -- User stays at current position - -### TreeSelectorComponent - -New TUI component at `src/modes/interactive/components/tree-selector.ts`: - -```typescript -interface TreeSelectorProps { - tree: SessionTreeNode[]; - currentLeafId: string; - onSelect: (entryId: string) => void; - onCancel: () => void; -} -``` - -Features: -- Height: half terminal height (capped) -- ASCII tree rendering with `├─`, `│`, `└─` connectors -- Depth-first traversal for up/down navigation -- Visual indicators: - - `← active` for current leaf - - Resolved labels shown inline - - Compaction nodes as `[compaction: Xk tokens]` -- Filter modes: - - Default: hide `label` and `custom` entries - - Ctrl+U: user messages only - - Ctrl+O: show all entries -- Scrolling with selected node kept visible +`/tree` command shows `TreeSelectorComponent`, then: +1. Prompt for summarization +2. Call `session.navigateTree()` +3. Clear and re-render chat +4. Set editor text if applicable ## Hook Events -These events are separate from `session_before_branch`/`session_branch` which are used by the existing `/branch` command (creates new session file). - ### `session_before_tree` -Fired before switching branches within the same session file. Hooks can cancel or provide custom summary. - ```typescript interface TreePreparation { - /** Node being switched to */ targetId: string; - /** Current active leaf (being abandoned) */ - oldLeafId: string; - /** Common ancestor of target and old leaf */ - commonAncestorId: string; - /** Entries to summarize (old leaf back to common ancestor or compaction) */ + oldLeafId: string | null; + commonAncestorId: string | null; entriesToSummarize: SessionEntry[]; - /** Whether user chose to summarize */ userWantsSummary: boolean; } interface SessionBeforeTreeEvent { type: "session_before_tree"; preparation: TreePreparation; - /** Model to use for summarization (conversation model) */ model: Model; - /** Abort signal - honors Escape during summarization */ signal: AbortSignal; } interface SessionBeforeTreeResult { - /** Cancel the navigation entirely */ cancel?: boolean; - /** Custom summary (skips default summarizer). Only used if userWantsSummary is true. */ - summary?: { - summary: string; - details?: unknown; - }; + summary?: { summary: string; details?: unknown }; } ``` ### `session_tree` -Fired after navigation completes successfully. Not fired if cancelled. - ```typescript interface SessionTreeEvent { type: "session_tree"; - /** The new active leaf */ - newLeafId: string; - /** Previous active leaf */ - oldLeafId: string; - /** Branch summary entry if one was created, undefined if user skipped summarization */ + newLeafId: string | null; + oldLeafId: string | null; summaryEntry?: BranchSummaryEntry; - /** Whether summary came from hook (false if default summarizer used, undefined if no summary) */ fromHook?: boolean; } ``` -### Example: Custom Branch Summarizer +### Example: Custom Summarizer ```typescript export default function(pi: HookAPI) { @@ -447,23 +184,14 @@ export default function(pi: HookAPI) { if (!event.preparation.userWantsSummary) return; if (event.preparation.entriesToSummarize.length === 0) return; - // Use a different model for summarization - const model = getModel("google", "gemini-2.5-flash"); - const apiKey = await ctx.modelRegistry.getApiKey(model); - - // Custom summarization logic - const summary = await summarizeWithCustomPrompt( - event.preparation.entriesToSummarize, - model, - apiKey - ); - - return { - summary: { - summary, - details: { model: model.id, timestamp: Date.now() } - } - }; + const summary = await myCustomSummarizer(event.preparation.entriesToSummarize); + return { summary: { summary, details: { custom: true } } }; }); } ``` + +## Error Handling + +- Summarization failure: cancels navigation, shows error +- User abort (Escape): cancels navigation +- Hook returns `cancel: true`: cancels navigation silently diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 11a9befd..ac5a86b4 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -34,12 +34,14 @@ import type { SessionBeforeCompactResult, SessionBeforeNewResult, SessionBeforeSwitchResult, + SessionBeforeTreeResult, + TreePreparation, TurnEndEvent, TurnStartEvent, } from "./hooks/index.js"; import type { BashExecutionMessage, HookMessage } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; -import type { CompactionEntry, SessionManager } from "./session-manager.js"; +import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.js"; import type { SettingsManager, SkillsSettings } from "./settings-manager.js"; import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js"; @@ -1554,6 +1556,261 @@ export class AgentSession { return { selectedText, cancelled: false }; } + // ========================================================================= + // Tree Navigation + // ========================================================================= + + /** + * Navigate to a different node in the session tree. + * Unlike branch() which creates a new session file, this stays in the same file. + * + * @param targetId The entry ID to navigate to + * @param options.summarize Whether user wants to summarize abandoned branch + * @param options.customInstructions Custom instructions for summarizer + * @returns Result with editorText (if user message) and cancelled status + */ + async navigateTree( + targetId: string, + options: { summarize?: boolean; customInstructions?: string } = {}, + ): Promise<{ editorText?: string; cancelled: boolean }> { + const oldLeafId = this.sessionManager.getLeafUuid(); + + // No-op if already at target + if (targetId === oldLeafId) { + return { cancelled: false }; + } + + // Model required for summarization + if (options.summarize && !this.model) { + throw new Error("No model available for summarization"); + } + + const targetEntry = this.sessionManager.getEntry(targetId); + if (!targetEntry) { + throw new Error(`Entry ${targetId} not found`); + } + + // Find common ancestor (if oldLeafId is null, there's no old path) + const oldPath = oldLeafId ? new Set(this.sessionManager.getPath(oldLeafId).map((e) => e.id)) : new Set(); + const targetPath = this.sessionManager.getPath(targetId); + let commonAncestorId: string | null = null; + for (const entry of targetPath) { + if (oldPath.has(entry.id)) { + commonAncestorId = entry.id; + break; + } + } + + // Collect entries to summarize (old leaf back to common ancestor, stop at compaction) + const entriesToSummarize: SessionEntry[] = []; + if (options.summarize && oldLeafId) { + let current: string | null = oldLeafId; + while (current && current !== commonAncestorId) { + const entry = this.sessionManager.getEntry(current); + if (!entry) break; + if (entry.type === "compaction") break; + entriesToSummarize.push(entry); + current = entry.parentId; + } + entriesToSummarize.reverse(); // Chronological order + } + + // Prepare event data + const preparation: TreePreparation = { + targetId, + oldLeafId, + commonAncestorId, + entriesToSummarize, + userWantsSummary: options.summarize ?? false, + }; + + // Set up abort controller for summarization + const abortController = new AbortController(); + let hookSummary: { summary: string; details?: unknown } | undefined; + let fromHook = false; + + // Emit session_before_tree event + if (this._hookRunner?.hasHandlers("session_before_tree")) { + const result = (await this._hookRunner.emit({ + type: "session_before_tree", + preparation, + model: this.model!, // Checked above if summarize is true + signal: abortController.signal, + })) as SessionBeforeTreeResult | undefined; + + if (result?.cancel) { + return { cancelled: true }; + } + + if (result?.summary && options.summarize) { + hookSummary = result.summary; + fromHook = true; + } + } + + // Run default summarizer if needed + let summaryText: string | undefined; + if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) { + try { + summaryText = await this._generateBranchSummary( + entriesToSummarize, + options.customInstructions, + abortController.signal, + ); + } catch { + // Summarization failed - cancel navigation + return { cancelled: true }; + } + } else if (hookSummary) { + summaryText = hookSummary.summary; + } + + // Determine the new leaf position based on target type + let newLeafId: string | null; + let editorText: string | undefined; + + if (targetEntry.type === "message" && targetEntry.message.role === "user") { + // User message: leaf = parent (null if root), text goes to editor + newLeafId = targetEntry.parentId; + editorText = this._extractUserMessageText(targetEntry.message.content); + } else if (targetEntry.type === "custom_message") { + // Custom message: leaf = parent (null if root), text goes to editor + newLeafId = targetEntry.parentId; + editorText = + typeof targetEntry.content === "string" + ? targetEntry.content + : targetEntry.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } else { + // Non-user message: leaf = selected node + newLeafId = targetId; + } + + // Switch leaf (with or without summary) + let summaryEntry: BranchSummaryEntry | undefined; + if (newLeafId === null) { + // Navigating to root user message - reset leaf to empty + this.sessionManager.resetLeaf(); + } else if (summaryText) { + const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText); + summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; + } else { + this.sessionManager.branch(newLeafId); + } + + // Update agent state + const sessionContext = this.sessionManager.buildSessionContext(); + this.agent.replaceMessages(sessionContext.messages); + + // Emit session_tree event + if (this._hookRunner) { + await this._hookRunner.emit({ + type: "session_tree", + newLeafId: this.sessionManager.getLeafUuid(), + oldLeafId, + summaryEntry, + fromHook: summaryText ? fromHook : undefined, + }); + } + + // Emit to custom tools + await this._emitToolSessionEvent("tree", this.sessionFile); + + return { editorText, cancelled: false }; + } + + /** + * Generate a summary of abandoned branch entries. + */ + private async _generateBranchSummary( + entries: SessionEntry[], + customInstructions: string | undefined, + signal: AbortSignal, + ): Promise { + // Convert entries to messages for summarization + const messages: Array<{ role: string; content: string }> = []; + for (const entry of entries) { + if (entry.type === "message") { + const text = this._extractMessageText(entry.message); + if (text) { + messages.push({ role: entry.message.role, content: text }); + } + } else if (entry.type === "custom_message") { + const text = + typeof entry.content === "string" + ? entry.content + : entry.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (text) { + messages.push({ role: "user", content: text }); + } + } else if (entry.type === "branch_summary") { + messages.push({ role: "system", content: `[Previous branch summary: ${entry.summary}]` }); + } + } + + if (messages.length === 0) { + return "No content to summarize"; + } + + // Build prompt for summarization + const conversationText = messages.map((m) => `${m.role}: ${m.content}`).join("\n\n"); + const instructions = customInstructions + ? `${customInstructions}\n\n` + : "Summarize this conversation branch concisely, capturing key decisions, actions taken, and outcomes.\n\n"; + + const prompt = `${instructions}Conversation:\n${conversationText}`; + + // Get API key for current model (model is checked in navigateTree before calling this) + const model = this.model!; + const apiKey = await this._modelRegistry.getApiKey(model); + if (!apiKey) { + throw new Error(`No API key for ${model.provider}`); + } + + // Call LLM for summarization + const { complete } = await import("@mariozechner/pi-ai"); + const response = await complete( + model, + { + messages: [ + { + role: "user", + content: [{ type: "text", text: prompt }], + timestamp: Date.now(), + }, + ], + }, + { apiKey, signal, maxTokens: 1024 }, + ); + + const summary = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + + return summary || "No summary generated"; + } + + /** + * Extract text content from any message type. + */ + private _extractMessageText(message: any): string { + if (!message.content) return ""; + if (typeof message.content === "string") return message.content; + if (Array.isArray(message.content)) { + return message.content + .filter((c: any) => c.type === "text") + .map((c: any) => c.text) + .join(""); + } + return ""; + } + /** * Get all user messages from session for branch selector. */ diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts index a9a69b99..28192b05 100644 --- a/packages/coding-agent/src/core/custom-tools/types.ts +++ b/packages/coding-agent/src/core/custom-tools/types.ts @@ -43,7 +43,7 @@ export interface SessionEvent { /** Previous session file path, or undefined for "start" and "new" */ previousSessionFile: string | undefined; /** Reason for the session event */ - reason: "start" | "switch" | "branch" | "new"; + reason: "start" | "switch" | "branch" | "new" | "tree"; } /** Rendering options passed to renderResult */ diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index eee9090a..fb908cf8 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -18,6 +18,7 @@ import type { HookUIContext, RegisteredCommand, SessionBeforeCompactResult, + SessionBeforeTreeResult, ToolCallEvent, ToolCallEventResult, ToolResultEventResult, @@ -231,12 +232,18 @@ export class HookRunner { */ private isSessionBeforeEvent( type: string, - ): type is "session_before_switch" | "session_before_new" | "session_before_branch" | "session_before_compact" { + ): type is + | "session_before_switch" + | "session_before_new" + | "session_before_branch" + | "session_before_compact" + | "session_before_tree" { return ( type === "session_before_switch" || type === "session_before_new" || type === "session_before_branch" || - type === "session_before_compact" + type === "session_before_compact" || + type === "session_before_tree" ); } @@ -244,9 +251,11 @@ export class HookRunner { * Emit an event to all hooks. * Returns the result from session before_* / tool_result events (if any handler returns one). */ - async emit(event: HookEvent): Promise { + async emit( + event: HookEvent, + ): Promise { const ctx = this.createContext(); - let result: SessionBeforeCompactResult | ToolResultEventResult | undefined; + let result: SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined; for (const hook of this.hooks) { const handlers = hook.handlers.get(event.type); @@ -267,7 +276,7 @@ export class HookRunner { // For session before_* events, capture the result (for cancellation) if (this.isSessionBeforeEvent(event.type) && handlerResult) { - result = handlerResult as SessionBeforeCompactResult; + result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult; // If cancelled, stop processing further hooks if (result.cancel) { return result; diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 950464d6..847a9134 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -13,7 +13,7 @@ import type { CompactionPreparation, CompactionResult } from "../compaction.js"; import type { ExecOptions, ExecResult } from "../exec.js"; import type { HookMessage } from "../messages.js"; import type { ModelRegistry } from "../model-registry.js"; -import type { CompactionEntry, SessionManager } from "../session-manager.js"; +import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "../session-manager.js"; /** * Read-only view of SessionManager for hooks. @@ -177,6 +177,44 @@ export interface SessionShutdownEvent { type: "session_shutdown"; } +/** Preparation data for tree navigation (used by session_before_tree event) */ +export interface TreePreparation { + /** Node being switched to */ + targetId: string; + /** Current active leaf (being abandoned), null if no current position */ + oldLeafId: string | null; + /** Common ancestor of target and old leaf, null if no common ancestor */ + commonAncestorId: string | null; + /** Entries to summarize (old leaf back to common ancestor or compaction) */ + entriesToSummarize: SessionEntry[]; + /** Whether user chose to summarize */ + userWantsSummary: boolean; +} + +/** Fired before navigating to a different node in the session tree (can be cancelled) */ +export interface SessionBeforeTreeEvent { + type: "session_before_tree"; + /** Preparation data for the navigation */ + preparation: TreePreparation; + /** Model to use for summarization (conversation model) */ + model: Model; + /** Abort signal - honors Escape during summarization */ + signal: AbortSignal; +} + +/** Fired after navigating to a different node in the session tree */ +export interface SessionTreeEvent { + type: "session_tree"; + /** The new active leaf, null if navigated to before first entry */ + newLeafId: string | null; + /** Previous active leaf, null if there was no position */ + oldLeafId: string | null; + /** Branch summary entry if one was created */ + summaryEntry?: BranchSummaryEntry; + /** Whether summary came from hook */ + fromHook?: boolean; +} + /** Union of all session event types */ export type SessionEvent = | SessionStartEvent @@ -188,7 +226,9 @@ export type SessionEvent = | SessionBranchEvent | SessionBeforeCompactEvent | SessionCompactEvent - | SessionShutdownEvent; + | SessionShutdownEvent + | SessionBeforeTreeEvent + | SessionTreeEvent; /** * Event data for context event. @@ -466,6 +506,20 @@ export interface SessionBeforeCompactResult { compaction?: CompactionResult; } +/** Return type for session_before_tree handlers */ +export interface SessionBeforeTreeResult { + /** If true, cancel the navigation entirely */ + cancel?: boolean; + /** + * Custom summary (skips default summarizer). + * Only used if preparation.userWantsSummary is true. + */ + summary?: { + summary: string; + details?: unknown; + }; +} + // ============================================================================ // Hook API // ============================================================================ @@ -539,6 +593,8 @@ export interface HookAPI { ): void; on(event: "session_compact", handler: HookHandler): void; on(event: "session_shutdown", handler: HookHandler): void; + on(event: "session_before_tree", handler: HookHandler): void; + on(event: "session_tree", handler: HookHandler): void; // Context and agent events on(event: "context", handler: HookHandler): void; diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 9a061eb6..ef319371 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -252,7 +252,7 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt */ export function buildSessionContext( entries: SessionEntry[], - leafId?: string, + leafId?: string | null, byId?: Map, ): SessionContext { // Build uuid index if not available @@ -265,11 +265,15 @@ export function buildSessionContext( // Find leaf let leaf: SessionEntry | undefined; + if (leafId === null) { + // Explicitly null - return no messages (navigated to before first entry) + return { messages: [], thinkingLevel: "off", model: null }; + } if (leafId) { leaf = byId.get(leafId); } if (!leaf) { - // Fallback to last entry + // Fallback to last entry (when leafId is undefined) leaf = entries[entries.length - 1]; } @@ -448,7 +452,7 @@ export class SessionManager { private fileEntries: FileEntry[] = []; private byId: Map = new Map(); private labelsById: Map = new Map(); - private leafId: string = ""; + private leafId: string | null = null; private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) { this.cwd = cwd; @@ -496,7 +500,7 @@ export class SessionManager { }; this.fileEntries = [header]; this.byId.clear(); - this.leafId = ""; + this.leafId = null; this.flushed = false; // Only generate filename if persisting and not already set (e.g., via --session flag) if (this.persist && !this.sessionFile) { @@ -509,7 +513,7 @@ export class SessionManager { private _buildIndex(): void { this.byId.clear(); this.labelsById.clear(); - this.leafId = ""; + this.leafId = null; for (const entry of this.fileEntries) { if (entry.type === "session") continue; this.byId.set(entry.id, entry); @@ -583,7 +587,7 @@ export class SessionManager { const entry: SessionMessageEntry = { type: "message", id: generateId(this.byId), - parentId: this.leafId || null, + parentId: this.leafId, timestamp: new Date().toISOString(), message, }; @@ -596,7 +600,7 @@ export class SessionManager { const entry: ThinkingLevelChangeEntry = { type: "thinking_level_change", id: generateId(this.byId), - parentId: this.leafId || null, + parentId: this.leafId, timestamp: new Date().toISOString(), thinkingLevel, }; @@ -609,7 +613,7 @@ export class SessionManager { const entry: ModelChangeEntry = { type: "model_change", id: generateId(this.byId), - parentId: this.leafId || null, + parentId: this.leafId, timestamp: new Date().toISOString(), provider, modelId, @@ -623,7 +627,7 @@ export class SessionManager { const entry: CompactionEntry = { type: "compaction", id: generateId(this.byId), - parentId: this.leafId || null, + parentId: this.leafId, timestamp: new Date().toISOString(), summary, firstKeptEntryId, @@ -641,7 +645,7 @@ export class SessionManager { customType, data, id: generateId(this.byId), - parentId: this.leafId || null, + parentId: this.leafId, timestamp: new Date().toISOString(), }; this._appendEntry(entry); @@ -669,7 +673,7 @@ export class SessionManager { display, details, id: generateId(this.byId), - parentId: this.leafId || null, + parentId: this.leafId, timestamp: new Date().toISOString(), }; this._appendEntry(entry); @@ -680,12 +684,12 @@ export class SessionManager { // Tree Traversal // ========================================================================= - getLeafUuid(): string { + getLeafUuid(): string | null { return this.leafId; } getLeafEntry(): SessionEntry | undefined { - return this.byId.get(this.leafId); + return this.leafId ? this.byId.get(this.leafId) : undefined; } getEntry(id: string): SessionEntry | undefined { @@ -711,7 +715,7 @@ export class SessionManager { const entry: LabelEntry = { type: "label", id: generateId(this.byId), - parentId: this.leafId || null, + parentId: this.leafId, timestamp: new Date().toISOString(), targetId, label, @@ -732,7 +736,8 @@ export class SessionManager { */ getPath(fromId?: string): SessionEntry[] { const path: SessionEntry[] = []; - let current = this.byId.get(fromId ?? this.leafId); + const startId = fromId ?? this.leafId; + let current = startId ? this.byId.get(startId) : undefined; while (current) { path.unshift(current); current = current.parentId ? this.byId.get(current.parentId) : undefined; @@ -797,6 +802,13 @@ export class SessionManager { } } + // Sort children by timestamp (oldest first, newest at bottom) + const sortChildren = (node: SessionTreeNode): void => { + node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()); + node.children.forEach(sortChildren); + }; + roots.forEach(sortChildren); + return roots; } @@ -817,6 +829,15 @@ export class SessionManager { this.leafId = branchFromId; } + /** + * Reset the leaf pointer to null (before any entries). + * The next appendXXX() call will create a new root entry (parentId = null). + * Use this when navigating to re-edit the first user message. + */ + resetLeaf(): void { + this.leafId = null; + } + /** * Start a new branch with a summary of the abandoned path. * Same as branch(), but also appends a branch_summary entry that captures diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts new file mode 100644 index 00000000..2c729b72 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -0,0 +1,315 @@ +import { + type Component, + Container, + isArrowDown, + isArrowUp, + isCtrlC, + isCtrlO, + isCtrlU, + isEnter, + isEscape, + Spacer, + Text, + truncateToWidth, +} from "@mariozechner/pi-tui"; +import type { SessionTreeNode } from "../../../core/session-manager.js"; +import { theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; + +/** Flattened tree node for navigation */ +interface FlatNode { + node: SessionTreeNode; + depth: number; + isLast: boolean; + /** Prefix chars showing tree structure (│, ├, └, spaces) */ + prefix: string; +} + +/** Filter mode for tree display */ +type FilterMode = "default" | "user-only" | "all"; + +/** + * Tree list component with selection and ASCII art visualization + */ +class TreeList implements Component { + private flatNodes: FlatNode[] = []; + private filteredNodes: FlatNode[] = []; + private selectedIndex = 0; + private currentLeafId: string | null; + private maxVisibleLines: number; + private filterMode: FilterMode = "default"; + + public onSelect?: (entryId: string) => void; + public onCancel?: () => void; + + constructor(tree: SessionTreeNode[], currentLeafId: string | null, maxVisibleLines: number) { + this.currentLeafId = currentLeafId; + this.maxVisibleLines = maxVisibleLines; + this.flatNodes = this.flattenTree(tree); + this.applyFilter(); + + // Start with current leaf selected + const leafIndex = this.filteredNodes.findIndex((n) => n.node.entry.id === currentLeafId); + if (leafIndex !== -1) { + this.selectedIndex = leafIndex; + } else { + this.selectedIndex = Math.max(0, this.filteredNodes.length - 1); + } + } + + private flattenTree(roots: SessionTreeNode[]): FlatNode[] { + const result: FlatNode[] = []; + + const traverse = (node: SessionTreeNode, depth: number, prefix: string, isLast: boolean) => { + result.push({ node, depth, isLast, prefix }); + + const children = node.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const childIsLast = i === children.length - 1; + const childPrefix = prefix + (isLast ? " " : "│ "); + traverse(child, depth + 1, childPrefix, childIsLast); + } + }; + + for (let i = 0; i < roots.length; i++) { + traverse(roots[i], 0, "", i === roots.length - 1); + } + + return result; + } + + private applyFilter(): void { + this.filteredNodes = this.flatNodes.filter((flatNode) => { + const entry = flatNode.node.entry; + + if (this.filterMode === "all") { + return true; + } + + if (this.filterMode === "user-only") { + return ( + (entry.type === "message" && entry.message.role === "user") || + (entry.type === "custom_message" && entry.display) + ); + } + + // Default mode: hide label and custom entries + return entry.type !== "label" && entry.type !== "custom"; + }); + + // Adjust selected index if needed + if (this.selectedIndex >= this.filteredNodes.length) { + this.selectedIndex = Math.max(0, this.filteredNodes.length - 1); + } + } + + invalidate(): void { + // No cached state to invalidate + } + + render(width: number): string[] { + const lines: string[] = []; + + if (this.filteredNodes.length === 0) { + lines.push(theme.fg("muted", " No entries found")); + return lines; + } + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min( + this.selectedIndex - Math.floor(this.maxVisibleLines / 2), + this.filteredNodes.length - this.maxVisibleLines, + ), + ); + const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length); + + for (let i = startIndex; i < endIndex; i++) { + const flatNode = this.filteredNodes[i]; + const entry = flatNode.node.entry; + const isSelected = i === this.selectedIndex; + const isCurrentLeaf = entry.id === this.currentLeafId; + + // Build tree connector + let connector = ""; + if (flatNode.depth > 0) { + connector = flatNode.prefix + (flatNode.isLast ? "└─ " : "├─ "); + } + + // Get entry display text + const displayText = this.getEntryDisplayText(flatNode.node, width - connector.length - 15); + + // Build suffix + let suffix = ""; + if (isCurrentLeaf) { + suffix = theme.fg("accent", " ← active"); + } + if (flatNode.node.label) { + suffix += theme.fg("warning", ` [${flatNode.node.label}]`); + } + + // Combine with selection indicator + const cursor = isSelected ? theme.fg("accent", "› ") : " "; + const text = isSelected ? theme.bold(displayText) : displayText; + const line = cursor + theme.fg("dim", connector) + text + suffix; + + lines.push(line); + } + + // Add scroll and filter info + const filterLabel = + this.filterMode === "default" ? "" : this.filterMode === "user-only" ? " [user only]" : " [all]"; + const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${filterLabel}`); + lines.push(scrollInfo); + + return lines; + } + + private getEntryDisplayText(node: SessionTreeNode, maxWidth: number): string { + const entry = node.entry; + + switch (entry.type) { + case "message": { + const msg = entry.message; + const role = msg.role; + // Handle messages that have content property + if (role === "user" || role === "assistant" || role === "toolResult") { + const msgWithContent = msg as { content?: unknown }; + const content = this.extractContent(msgWithContent.content); + const roleColor = role === "user" ? "accent" : role === "assistant" ? "success" : "muted"; + const roleLabel = theme.fg(roleColor, `${role}: `); + const truncated = truncateToWidth(content.replace(/\n/g, " ").trim(), maxWidth - role.length - 2); + return roleLabel + truncated; + } + // Handle special message types + if (role === "bashExecution") { + const bashMsg = msg as { command?: string }; + return theme.fg("dim", `[bash]: ${bashMsg.command ?? ""}`); + } + if (role === "compactionSummary" || role === "branchSummary" || role === "hookMessage") { + return theme.fg("dim", `[${role}]`); + } + return theme.fg("dim", `[${role}]`); + } + case "custom_message": { + const content = + typeof entry.content === "string" + ? entry.content + : entry.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + const label = theme.fg("customMessageLabel", `[${entry.customType}]: `); + const truncated = truncateToWidth( + content.replace(/\n/g, " ").trim(), + maxWidth - entry.customType.length - 4, + ); + return label + truncated; + } + case "compaction": { + const tokens = Math.round(entry.tokensBefore / 1000); + return theme.fg("borderAccent", `[compaction: ${tokens}k tokens]`); + } + case "branch_summary": { + const truncated = truncateToWidth(entry.summary.replace(/\n/g, " ").trim(), maxWidth - 20); + return theme.fg("warning", `[branch summary]: `) + truncated; + } + case "model_change": { + return theme.fg("dim", `[model: ${entry.modelId}]`); + } + case "thinking_level_change": { + return theme.fg("dim", `[thinking: ${entry.thinkingLevel}]`); + } + case "custom": { + return theme.fg("dim", `[custom: ${entry.customType}]`); + } + case "label": { + return theme.fg("dim", `[label: ${entry.label ?? "(cleared)"}]`); + } + } + } + + private extractContent(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((c) => typeof c === "object" && c !== null && "type" in c && c.type === "text") + .map((c) => (c as { text: string }).text) + .join(""); + } + return ""; + } + + handleInput(keyData: string): void { + if (isArrowUp(keyData)) { + this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1; + } else if (isArrowDown(keyData)) { + this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1; + } else if (isEnter(keyData)) { + const selected = this.filteredNodes[this.selectedIndex]; + if (selected && this.onSelect) { + this.onSelect(selected.node.entry.id); + } + } else if (isEscape(keyData) || isCtrlC(keyData)) { + this.onCancel?.(); + } else if (isCtrlU(keyData)) { + // Toggle user-only filter + this.filterMode = this.filterMode === "user-only" ? "default" : "user-only"; + this.applyFilter(); + } else if (isCtrlO(keyData)) { + // Toggle show-all filter + this.filterMode = this.filterMode === "all" ? "default" : "all"; + this.applyFilter(); + } + } +} + +/** + * Component that renders a session tree selector for navigation + */ +export class TreeSelectorComponent extends Container { + private treeList: TreeList; + + constructor( + tree: SessionTreeNode[], + currentLeafId: string | null, + terminalHeight: number, + onSelect: (entryId: string) => void, + onCancel: () => void, + ) { + super(); + + // Cap at half terminal height + const maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2)); + + // Add header + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.bold("Session Tree"), 1, 0)); + this.addChild(new Text(theme.fg("muted", "Navigate to a different point. Ctrl+U: user only, Ctrl+O: all"), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + + // Create tree list + this.treeList = new TreeList(tree, currentLeafId, maxVisibleLines); + this.treeList.onSelect = onSelect; + this.treeList.onCancel = onCancel; + + this.addChild(this.treeList); + + // Add bottom border + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + + // Auto-cancel if empty tree + if (tree.length === 0) { + setTimeout(() => onCancel(), 100); + } + } + + getTreeList(): TreeList { + return this.treeList; + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4d804832..70185bc0 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -51,6 +51,7 @@ import { OAuthSelectorComponent } from "./components/oauth-selector.js"; import { SessionSelectorComponent } from "./components/session-selector.js"; import { SettingsSelectorComponent } from "./components/settings-selector.js"; import { ToolExecutionComponent } from "./components/tool-execution.js"; +import { TreeSelectorComponent } from "./components/tree-selector.js"; import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; import { getAvailableThemes, getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js"; @@ -155,6 +156,7 @@ export class InteractiveMode { { name: "changelog", description: "Show changelog entries" }, { name: "hotkeys", description: "Show all keyboard shortcuts" }, { name: "branch", description: "Create a new branch from a previous message" }, + { name: "tree", description: "Navigate session tree (switch branches)" }, { name: "login", description: "Login with OAuth provider" }, { name: "logout", description: "Logout from OAuth provider" }, { name: "new", description: "Start a new session" }, @@ -679,6 +681,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/tree") { + this.showTreeSelector(); + this.editor.setText(""); + return; + } if (text === "/login") { this.showOAuthSelector("login"); this.editor.setText(""); @@ -1585,6 +1592,63 @@ export class InteractiveMode { }); } + private showTreeSelector(): void { + const tree = this.sessionManager.getTree(); + const currentLeafId = this.sessionManager.getLeafUuid(); + + if (tree.length === 0) { + this.showStatus("No entries in session"); + return; + } + + this.showSelector((done) => { + const selector = new TreeSelectorComponent( + tree, + currentLeafId, + this.ui.terminal.rows, + async (entryId) => { + // Check if selecting current leaf (no-op) + if (entryId === currentLeafId) { + done(); + this.showStatus("Already at this point"); + return; + } + + // Ask about summarization + done(); // Close selector first + + const wantsSummary = await this.showHookConfirm( + "Summarize branch?", + "Create a summary of the branch you're leaving?", + ); + + try { + const result = await this.session.navigateTree(entryId, { summarize: wantsSummary }); + if (result.cancelled) { + this.showStatus("Navigation cancelled"); + return; + } + + // Update UI + this.chatContainer.clear(); + this.renderInitialMessages(); + if (result.editorText) { + this.editor.setText(result.editorText); + } + this.showStatus("Navigated to selected point"); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + }, + () => { + done(); + this.ui.requestRender(); + }, + ); + return { component: selector, focus: selector.getTreeList() }; + }); + } + private showSessionSelector(): void { this.showSelector((done) => { const sessions = SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir());