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
This commit is contained in:
Mario Zechner 2025-12-29 02:29:35 +01:00
parent 256761e410
commit 4958271dd3
9 changed files with 893 additions and 443 deletions

View file

@ -6104,9 +6104,9 @@ export const MODELS = {
contextWindow: 32768, contextWindow: 32768,
maxTokens: 4096, maxTokens: 4096,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"anthropic/claude-3.5-haiku-20241022": { "anthropic/claude-3.5-haiku": {
id: "anthropic/claude-3.5-haiku-20241022", id: "anthropic/claude-3.5-haiku",
name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", name: "Anthropic: Claude 3.5 Haiku",
api: "openai-completions", api: "openai-completions",
provider: "openrouter", provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1", baseUrl: "https://openrouter.ai/api/v1",
@ -6121,9 +6121,9 @@ export const MODELS = {
contextWindow: 200000, contextWindow: 200000,
maxTokens: 8192, maxTokens: 8192,
} satisfies Model<"openai-completions">, } satisfies Model<"openai-completions">,
"anthropic/claude-3.5-haiku": { "anthropic/claude-3.5-haiku-20241022": {
id: "anthropic/claude-3.5-haiku", id: "anthropic/claude-3.5-haiku-20241022",
name: "Anthropic: Claude 3.5 Haiku", name: "Anthropic: Claude 3.5 Haiku (2024-10-22)",
api: "openai-completions", api: "openai-completions",
provider: "openrouter", provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1", baseUrl: "https://openrouter.ai/api/v1",
@ -6359,23 +6359,6 @@ export const MODELS = {
contextWindow: 128000, contextWindow: 128000,
maxTokens: 16384, maxTokens: 16384,
} satisfies Model<"openai-completions">, } 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": { "meta-llama/llama-3.1-405b-instruct": {
id: "meta-llama/llama-3.1-405b-instruct", id: "meta-llama/llama-3.1-405b-instruct",
name: "Meta: Llama 3.1 405B Instruct", name: "Meta: Llama 3.1 405B Instruct",
@ -6410,6 +6393,23 @@ export const MODELS = {
contextWindow: 131072, contextWindow: 131072,
maxTokens: 4096, maxTokens: 4096,
} satisfies Model<"openai-completions">, } 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": { "mistralai/mistral-nemo": {
id: "mistralai/mistral-nemo", id: "mistralai/mistral-nemo",
name: "Mistral: Mistral Nemo", name: "Mistral: Mistral Nemo",
@ -6546,23 +6546,6 @@ export const MODELS = {
contextWindow: 128000, contextWindow: 128000,
maxTokens: 4096, maxTokens: 4096,
} satisfies Model<"openai-completions">, } 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": { "openai/gpt-4o": {
id: "openai/gpt-4o", id: "openai/gpt-4o",
name: "OpenAI: GPT-4o", name: "OpenAI: GPT-4o",
@ -6597,6 +6580,23 @@ export const MODELS = {
contextWindow: 128000, contextWindow: 128000,
maxTokens: 64000, maxTokens: 64000,
} satisfies Model<"openai-completions">, } 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": { "meta-llama/llama-3-70b-instruct": {
id: "meta-llama/llama-3-70b-instruct", id: "meta-llama/llama-3-70b-instruct",
name: "Meta: Llama 3 70B Instruct", name: "Meta: Llama 3 70B Instruct",
@ -6835,23 +6835,6 @@ export const MODELS = {
contextWindow: 8191, contextWindow: 8191,
maxTokens: 4096, maxTokens: 4096,
} satisfies Model<"openai-completions">, } 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": { "openai/gpt-3.5-turbo": {
id: "openai/gpt-3.5-turbo", id: "openai/gpt-3.5-turbo",
name: "OpenAI: GPT-3.5 Turbo", name: "OpenAI: GPT-3.5 Turbo",
@ -6869,6 +6852,23 @@ export const MODELS = {
contextWindow: 16385, contextWindow: 16385,
maxTokens: 4096, maxTokens: 4096,
} satisfies Model<"openai-completions">, } 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": { "openrouter/auto": {
id: "openrouter/auto", id: "openrouter/auto",
name: "OpenRouter: Auto Router", name: "OpenRouter: Auto Router",

View file

@ -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 ## Overview
The `/tree` command provides tree-based navigation of the session history, allowing users to: 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.
1. View the entire session tree structure
2. Switch to any branch point
3. Optionally summarize the branch being abandoned
This differs from `/branch` which extracts a linear path to a new session file. ### Comparison with `/branch`
## Commands | Feature | `/branch` | `/tree` |
|---------|-----------|---------|
### `/branch` (existing) | View | Flat list of user messages | Full tree structure |
- Shows a flat list of user messages | Action | Extracts path to **new session file** | Changes leaf in **same session** |
- Extracts selected path to a **new session file** | Summary | Never | Optional (user prompted) |
- Selected user message text goes to editor for re-submission | Events | `session_before_branch` / `session_branch` | `session_before_tree` / `session_tree` |
- 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
## Tree UI ## Tree UI
The tree selector displays the session structure with ASCII art:
``` ```
├─ user: "Hello, can you help..." ├─ user: "Hello, can you help..."
│ └─ assistant: "Of course! I can..." │ └─ assistant: "Of course! I can..."
│ ├─ user: "Let's try approach A..." │ ├─ user: "Let's try approach A..."
│ │ └─ assistant: "For approach A..." │ │ └─ assistant: "For approach A..."
│ │ └─ [compaction: 12k tokens] │ │ └─ [compaction: 12k tokens]
│ │ └─ user: "That worked, now..." │ │ └─ user: "That worked..." ← active
│ │ └─ assistant: "Great! Next..." ← active
│ └─ user: "Actually, approach B..." │ └─ user: "Actually, approach B..."
│ └─ assistant: "For approach B..." │ └─ assistant: "For approach B..."
``` ```
### Visual Indicators ### Controls
| 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
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| ↑/↓ | Move through nodes (depth-first pre-order) | | ↑/↓ | Navigate (depth-first order) |
| Enter | Select node and proceed | | Enter | Select node |
| Escape | Cancel | | Escape/Ctrl+C | Cancel |
| Ctrl+C | Cancel | | Ctrl+U | Toggle: user messages only |
| Ctrl+U | Toggle: show only user messages | | Ctrl+O | Toggle: show all (including custom/label entries) |
| Ctrl+O | Toggle: show all entries (including custom/label) |
### Filtering ### Display
Default view hides: - Height: half terminal height
- `label` entries (labels shown inline on their target node) - Current leaf marked with `← active`
- `custom` entries (hook state, not relevant for navigation) - Labels shown inline: `[label-name]`
- Default filter hides `label` and `custom` entries (shown in Ctrl+O mode)
Ctrl+O shows everything for debugging/inspection. - Children sorted by timestamp (oldest first)
### Component Size
Height is capped at **half terminal height** to show substantial tree context without overshooting the terminal.
## Selection Behavior ## 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 ### Selecting Root User Message
If user selects the very first message (has no parent):
### User Message or Custom Message Selected 1. Leaf reset to `null` (empty conversation)
1. Active leaf is set to **parent** of selected node 2. Message text placed in editor
2. Selected message text is placed in the **editor** for re-submission 3. User effectively restarts from scratch
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
## Branch Summarization ## 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 ### 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 A → B → C → D → E → F ← old leaf
↘ G → H ← user selects H ↘ G → H ← target
``` ```
- Common ancestor: C Abandoned path: D → E → F (summarized)
- Abandoned path: D → E → F
- These nodes are summarized
### Stopping Conditions Summarization stops at:
1. Common ancestor (always)
When walking back from the old leaf to gather content for summarization: 2. Compaction node (if encountered first)
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
### Summary Storage ### Summary Storage
The summary is stored as a `BranchSummaryEntry`: Stored as `BranchSummaryEntry`:
```typescript ```typescript
interface BranchSummaryEntry { interface BranchSummaryEntry {
type: "branch_summary"; type: "branch_summary";
id: string; id: string;
parentId: string; // Points to common ancestor parentId: string; // New leaf position
timestamp: string; timestamp: string;
fromId: string; // The old leaf we abandoned fromId: string; // Old leaf we abandoned
summary: string; // LLM-generated summary summary: string; // LLM-generated summary
details?: unknown; // Optional hook data 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 ### AgentSession.navigateTree()
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
```typescript ```typescript
interface NavigateTreeOptions { async navigateTree(
/** Whether user wants to summarize abandoned branch */ targetId: string,
summarize?: boolean; options?: { summarize?: boolean; customInstructions?: string }
/** Custom instructions for summarizer */ ): Promise<{ editorText?: string; cancelled: boolean }>
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<NavigateTreeResult>
``` ```
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** ### SessionManager
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 }`
### 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 ### InteractiveMode
if (text === "/tree") {
this.showTreeSelector();
this.editor.setText("");
return;
}
```
`showTreeSelector()` flow: `/tree` command shows `TreeSelectorComponent`, then:
1. Prompt for summarization
1. Get tree via `sessionManager.getTree()` 2. Call `session.navigateTree()`
2. Show `TreeSelectorComponent` (new component) 3. Clear and re-render chat
3. On selection: 4. Set editor text if applicable
- 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
## Hook Events ## 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` ### `session_before_tree`
Fired before switching branches within the same session file. Hooks can cancel or provide custom summary.
```typescript ```typescript
interface TreePreparation { interface TreePreparation {
/** Node being switched to */
targetId: string; targetId: string;
/** Current active leaf (being abandoned) */ oldLeafId: string | null;
oldLeafId: string; commonAncestorId: string | null;
/** Common ancestor of target and old leaf */
commonAncestorId: string;
/** Entries to summarize (old leaf back to common ancestor or compaction) */
entriesToSummarize: SessionEntry[]; entriesToSummarize: SessionEntry[];
/** Whether user chose to summarize */
userWantsSummary: boolean; userWantsSummary: boolean;
} }
interface SessionBeforeTreeEvent { interface SessionBeforeTreeEvent {
type: "session_before_tree"; type: "session_before_tree";
preparation: TreePreparation; preparation: TreePreparation;
/** Model to use for summarization (conversation model) */
model: Model; model: Model;
/** Abort signal - honors Escape during summarization */
signal: AbortSignal; signal: AbortSignal;
} }
interface SessionBeforeTreeResult { interface SessionBeforeTreeResult {
/** Cancel the navigation entirely */
cancel?: boolean; 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` ### `session_tree`
Fired after navigation completes successfully. Not fired if cancelled.
```typescript ```typescript
interface SessionTreeEvent { interface SessionTreeEvent {
type: "session_tree"; type: "session_tree";
/** The new active leaf */ newLeafId: string | null;
newLeafId: string; oldLeafId: string | null;
/** Previous active leaf */
oldLeafId: string;
/** Branch summary entry if one was created, undefined if user skipped summarization */
summaryEntry?: BranchSummaryEntry; summaryEntry?: BranchSummaryEntry;
/** Whether summary came from hook (false if default summarizer used, undefined if no summary) */
fromHook?: boolean; fromHook?: boolean;
} }
``` ```
### Example: Custom Branch Summarizer ### Example: Custom Summarizer
```typescript ```typescript
export default function(pi: HookAPI) { export default function(pi: HookAPI) {
@ -447,23 +184,14 @@ export default function(pi: HookAPI) {
if (!event.preparation.userWantsSummary) return; if (!event.preparation.userWantsSummary) return;
if (event.preparation.entriesToSummarize.length === 0) return; if (event.preparation.entriesToSummarize.length === 0) return;
// Use a different model for summarization const summary = await myCustomSummarizer(event.preparation.entriesToSummarize);
const model = getModel("google", "gemini-2.5-flash"); return { summary: { summary, details: { custom: true } } };
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() }
}
};
}); });
} }
``` ```
## Error Handling
- Summarization failure: cancels navigation, shows error
- User abort (Escape): cancels navigation
- Hook returns `cancel: true`: cancels navigation silently

View file

@ -34,12 +34,14 @@ import type {
SessionBeforeCompactResult, SessionBeforeCompactResult,
SessionBeforeNewResult, SessionBeforeNewResult,
SessionBeforeSwitchResult, SessionBeforeSwitchResult,
SessionBeforeTreeResult,
TreePreparation,
TurnEndEvent, TurnEndEvent,
TurnStartEvent, TurnStartEvent,
} from "./hooks/index.js"; } from "./hooks/index.js";
import type { BashExecutionMessage, HookMessage } from "./messages.js"; import type { BashExecutionMessage, HookMessage } from "./messages.js";
import type { ModelRegistry } from "./model-registry.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 type { SettingsManager, SkillsSettings } from "./settings-manager.js";
import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js"; import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js";
@ -1554,6 +1556,261 @@ export class AgentSession {
return { selectedText, cancelled: false }; 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<string>();
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<string> {
// 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. * Get all user messages from session for branch selector.
*/ */

View file

@ -43,7 +43,7 @@ export interface SessionEvent {
/** Previous session file path, or undefined for "start" and "new" */ /** Previous session file path, or undefined for "start" and "new" */
previousSessionFile: string | undefined; previousSessionFile: string | undefined;
/** Reason for the session event */ /** Reason for the session event */
reason: "start" | "switch" | "branch" | "new"; reason: "start" | "switch" | "branch" | "new" | "tree";
} }
/** Rendering options passed to renderResult */ /** Rendering options passed to renderResult */

View file

@ -18,6 +18,7 @@ import type {
HookUIContext, HookUIContext,
RegisteredCommand, RegisteredCommand,
SessionBeforeCompactResult, SessionBeforeCompactResult,
SessionBeforeTreeResult,
ToolCallEvent, ToolCallEvent,
ToolCallEventResult, ToolCallEventResult,
ToolResultEventResult, ToolResultEventResult,
@ -231,12 +232,18 @@ export class HookRunner {
*/ */
private isSessionBeforeEvent( private isSessionBeforeEvent(
type: string, 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 ( return (
type === "session_before_switch" || type === "session_before_switch" ||
type === "session_before_new" || type === "session_before_new" ||
type === "session_before_branch" || 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. * Emit an event to all hooks.
* Returns the result from session before_* / tool_result events (if any handler returns one). * Returns the result from session before_* / tool_result events (if any handler returns one).
*/ */
async emit(event: HookEvent): Promise<SessionBeforeCompactResult | ToolResultEventResult | undefined> { async emit(
event: HookEvent,
): Promise<SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined> {
const ctx = this.createContext(); const ctx = this.createContext();
let result: SessionBeforeCompactResult | ToolResultEventResult | undefined; let result: SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined;
for (const hook of this.hooks) { for (const hook of this.hooks) {
const handlers = hook.handlers.get(event.type); const handlers = hook.handlers.get(event.type);
@ -267,7 +276,7 @@ export class HookRunner {
// For session before_* events, capture the result (for cancellation) // For session before_* events, capture the result (for cancellation)
if (this.isSessionBeforeEvent(event.type) && handlerResult) { if (this.isSessionBeforeEvent(event.type) && handlerResult) {
result = handlerResult as SessionBeforeCompactResult; result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult;
// If cancelled, stop processing further hooks // If cancelled, stop processing further hooks
if (result.cancel) { if (result.cancel) {
return result; return result;

View file

@ -13,7 +13,7 @@ import type { CompactionPreparation, CompactionResult } from "../compaction.js";
import type { ExecOptions, ExecResult } from "../exec.js"; import type { ExecOptions, ExecResult } from "../exec.js";
import type { HookMessage } from "../messages.js"; import type { HookMessage } from "../messages.js";
import type { ModelRegistry } from "../model-registry.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. * Read-only view of SessionManager for hooks.
@ -177,6 +177,44 @@ export interface SessionShutdownEvent {
type: "session_shutdown"; 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<any>;
/** 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 */ /** Union of all session event types */
export type SessionEvent = export type SessionEvent =
| SessionStartEvent | SessionStartEvent
@ -188,7 +226,9 @@ export type SessionEvent =
| SessionBranchEvent | SessionBranchEvent
| SessionBeforeCompactEvent | SessionBeforeCompactEvent
| SessionCompactEvent | SessionCompactEvent
| SessionShutdownEvent; | SessionShutdownEvent
| SessionBeforeTreeEvent
| SessionTreeEvent;
/** /**
* Event data for context event. * Event data for context event.
@ -466,6 +506,20 @@ export interface SessionBeforeCompactResult {
compaction?: CompactionResult; 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 // Hook API
// ============================================================================ // ============================================================================
@ -539,6 +593,8 @@ export interface HookAPI {
): void; ): void;
on(event: "session_compact", handler: HookHandler<SessionCompactEvent>): void; on(event: "session_compact", handler: HookHandler<SessionCompactEvent>): void;
on(event: "session_shutdown", handler: HookHandler<SessionShutdownEvent>): void; on(event: "session_shutdown", handler: HookHandler<SessionShutdownEvent>): void;
on(event: "session_before_tree", handler: HookHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;
on(event: "session_tree", handler: HookHandler<SessionTreeEvent>): void;
// Context and agent events // Context and agent events
on(event: "context", handler: HookHandler<ContextEvent, ContextEventResult>): void; on(event: "context", handler: HookHandler<ContextEvent, ContextEventResult>): void;

View file

@ -252,7 +252,7 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt
*/ */
export function buildSessionContext( export function buildSessionContext(
entries: SessionEntry[], entries: SessionEntry[],
leafId?: string, leafId?: string | null,
byId?: Map<string, SessionEntry>, byId?: Map<string, SessionEntry>,
): SessionContext { ): SessionContext {
// Build uuid index if not available // Build uuid index if not available
@ -265,11 +265,15 @@ export function buildSessionContext(
// Find leaf // Find leaf
let leaf: SessionEntry | undefined; 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) { if (leafId) {
leaf = byId.get(leafId); leaf = byId.get(leafId);
} }
if (!leaf) { if (!leaf) {
// Fallback to last entry // Fallback to last entry (when leafId is undefined)
leaf = entries[entries.length - 1]; leaf = entries[entries.length - 1];
} }
@ -448,7 +452,7 @@ export class SessionManager {
private fileEntries: FileEntry[] = []; private fileEntries: FileEntry[] = [];
private byId: Map<string, SessionEntry> = new Map(); private byId: Map<string, SessionEntry> = new Map();
private labelsById: Map<string, string> = new Map(); private labelsById: Map<string, string> = new Map();
private leafId: string = ""; private leafId: string | null = null;
private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) { private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {
this.cwd = cwd; this.cwd = cwd;
@ -496,7 +500,7 @@ export class SessionManager {
}; };
this.fileEntries = [header]; this.fileEntries = [header];
this.byId.clear(); this.byId.clear();
this.leafId = ""; this.leafId = null;
this.flushed = false; this.flushed = false;
// Only generate filename if persisting and not already set (e.g., via --session flag) // Only generate filename if persisting and not already set (e.g., via --session flag)
if (this.persist && !this.sessionFile) { if (this.persist && !this.sessionFile) {
@ -509,7 +513,7 @@ export class SessionManager {
private _buildIndex(): void { private _buildIndex(): void {
this.byId.clear(); this.byId.clear();
this.labelsById.clear(); this.labelsById.clear();
this.leafId = ""; this.leafId = null;
for (const entry of this.fileEntries) { for (const entry of this.fileEntries) {
if (entry.type === "session") continue; if (entry.type === "session") continue;
this.byId.set(entry.id, entry); this.byId.set(entry.id, entry);
@ -583,7 +587,7 @@ export class SessionManager {
const entry: SessionMessageEntry = { const entry: SessionMessageEntry = {
type: "message", type: "message",
id: generateId(this.byId), id: generateId(this.byId),
parentId: this.leafId || null, parentId: this.leafId,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
message, message,
}; };
@ -596,7 +600,7 @@ export class SessionManager {
const entry: ThinkingLevelChangeEntry = { const entry: ThinkingLevelChangeEntry = {
type: "thinking_level_change", type: "thinking_level_change",
id: generateId(this.byId), id: generateId(this.byId),
parentId: this.leafId || null, parentId: this.leafId,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
thinkingLevel, thinkingLevel,
}; };
@ -609,7 +613,7 @@ export class SessionManager {
const entry: ModelChangeEntry = { const entry: ModelChangeEntry = {
type: "model_change", type: "model_change",
id: generateId(this.byId), id: generateId(this.byId),
parentId: this.leafId || null, parentId: this.leafId,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
provider, provider,
modelId, modelId,
@ -623,7 +627,7 @@ export class SessionManager {
const entry: CompactionEntry<T> = { const entry: CompactionEntry<T> = {
type: "compaction", type: "compaction",
id: generateId(this.byId), id: generateId(this.byId),
parentId: this.leafId || null, parentId: this.leafId,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
summary, summary,
firstKeptEntryId, firstKeptEntryId,
@ -641,7 +645,7 @@ export class SessionManager {
customType, customType,
data, data,
id: generateId(this.byId), id: generateId(this.byId),
parentId: this.leafId || null, parentId: this.leafId,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
this._appendEntry(entry); this._appendEntry(entry);
@ -669,7 +673,7 @@ export class SessionManager {
display, display,
details, details,
id: generateId(this.byId), id: generateId(this.byId),
parentId: this.leafId || null, parentId: this.leafId,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
this._appendEntry(entry); this._appendEntry(entry);
@ -680,12 +684,12 @@ export class SessionManager {
// Tree Traversal // Tree Traversal
// ========================================================================= // =========================================================================
getLeafUuid(): string { getLeafUuid(): string | null {
return this.leafId; return this.leafId;
} }
getLeafEntry(): SessionEntry | undefined { getLeafEntry(): SessionEntry | undefined {
return this.byId.get(this.leafId); return this.leafId ? this.byId.get(this.leafId) : undefined;
} }
getEntry(id: string): SessionEntry | undefined { getEntry(id: string): SessionEntry | undefined {
@ -711,7 +715,7 @@ export class SessionManager {
const entry: LabelEntry = { const entry: LabelEntry = {
type: "label", type: "label",
id: generateId(this.byId), id: generateId(this.byId),
parentId: this.leafId || null, parentId: this.leafId,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
targetId, targetId,
label, label,
@ -732,7 +736,8 @@ export class SessionManager {
*/ */
getPath(fromId?: string): SessionEntry[] { getPath(fromId?: string): SessionEntry[] {
const path: 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) { while (current) {
path.unshift(current); path.unshift(current);
current = current.parentId ? this.byId.get(current.parentId) : undefined; 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; return roots;
} }
@ -817,6 +829,15 @@ export class SessionManager {
this.leafId = branchFromId; 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. * Start a new branch with a summary of the abandoned path.
* Same as branch(), but also appends a branch_summary entry that captures * Same as branch(), but also appends a branch_summary entry that captures

View file

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

View file

@ -51,6 +51,7 @@ import { OAuthSelectorComponent } from "./components/oauth-selector.js";
import { SessionSelectorComponent } from "./components/session-selector.js"; import { SessionSelectorComponent } from "./components/session-selector.js";
import { SettingsSelectorComponent } from "./components/settings-selector.js"; import { SettingsSelectorComponent } from "./components/settings-selector.js";
import { ToolExecutionComponent } from "./components/tool-execution.js"; import { ToolExecutionComponent } from "./components/tool-execution.js";
import { TreeSelectorComponent } from "./components/tree-selector.js";
import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageComponent } from "./components/user-message.js";
import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
import { getAvailableThemes, getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.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: "changelog", description: "Show changelog entries" },
{ name: "hotkeys", description: "Show all keyboard shortcuts" }, { name: "hotkeys", description: "Show all keyboard shortcuts" },
{ name: "branch", description: "Create a new branch from a previous message" }, { name: "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: "login", description: "Login with OAuth provider" },
{ name: "logout", description: "Logout from OAuth provider" }, { name: "logout", description: "Logout from OAuth provider" },
{ name: "new", description: "Start a new session" }, { name: "new", description: "Start a new session" },
@ -679,6 +681,11 @@ export class InteractiveMode {
this.editor.setText(""); this.editor.setText("");
return; return;
} }
if (text === "/tree") {
this.showTreeSelector();
this.editor.setText("");
return;
}
if (text === "/login") { if (text === "/login") {
this.showOAuthSelector("login"); this.showOAuthSelector("login");
this.editor.setText(""); 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 { private showSessionSelector(): void {
this.showSelector((done) => { this.showSelector((done) => {
const sessions = SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir()); const sessions = SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir());