mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 03:04:28 +00:00
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:
parent
256761e410
commit
4958271dd3
9 changed files with 893 additions and 443 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue