mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 20:03:05 +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
|
|
@ -1,445 +1,182 @@
|
|||
# Branch Summary
|
||||
# Session Tree Navigation
|
||||
|
||||
This document describes the `/tree` command and branch summarization feature.
|
||||
The `/tree` command provides tree-based navigation of the session history.
|
||||
|
||||
## Overview
|
||||
|
||||
The `/tree` command provides tree-based navigation of the session history, allowing users to:
|
||||
1. View the entire session tree structure
|
||||
2. Switch to any branch point
|
||||
3. Optionally summarize the branch being abandoned
|
||||
Sessions are stored as trees where each entry has an `id` and `parentId`. The "leaf" pointer tracks the current position. `/tree` lets you navigate to any point and optionally summarize the branch you're leaving.
|
||||
|
||||
This differs from `/branch` which extracts a linear path to a new session file.
|
||||
### Comparison with `/branch`
|
||||
|
||||
## Commands
|
||||
|
||||
### `/branch` (existing)
|
||||
- Shows a flat list of user messages
|
||||
- Extracts selected path to a **new session file**
|
||||
- Selected user message text goes to editor for re-submission
|
||||
- Fires `session_before_branch` / `session_branch` events
|
||||
|
||||
### `/tree` (new)
|
||||
- Shows the **full session tree** with visual hierarchy
|
||||
- Navigates within the **same session file** (changes active leaf)
|
||||
- Optionally summarizes the abandoned branch
|
||||
- Fires `session_before_tree` / `session_tree` events
|
||||
| Feature | `/branch` | `/tree` |
|
||||
|---------|-----------|---------|
|
||||
| View | Flat list of user messages | Full tree structure |
|
||||
| Action | Extracts path to **new session file** | Changes leaf in **same session** |
|
||||
| Summary | Never | Optional (user prompted) |
|
||||
| Events | `session_before_branch` / `session_branch` | `session_before_tree` / `session_tree` |
|
||||
|
||||
## Tree UI
|
||||
|
||||
The tree selector displays the session structure with ASCII art:
|
||||
|
||||
```
|
||||
├─ user: "Hello, can you help..."
|
||||
│ └─ assistant: "Of course! I can..."
|
||||
│ ├─ user: "Let's try approach A..."
|
||||
│ │ └─ assistant: "For approach A..."
|
||||
│ │ └─ [compaction: 12k tokens]
|
||||
│ │ └─ user: "That worked, now..."
|
||||
│ │ └─ assistant: "Great! Next..." ← active
|
||||
│ │ └─ user: "That worked..." ← active
|
||||
│ └─ user: "Actually, approach B..."
|
||||
│ └─ assistant: "For approach B..."
|
||||
```
|
||||
|
||||
### Visual Indicators
|
||||
|
||||
| Element | Display |
|
||||
|---------|---------|
|
||||
| Current active leaf | `← active` suffix, highlighted |
|
||||
| User messages | Normal color (selectable) |
|
||||
| Custom messages (display: true) | Normal color (selectable) |
|
||||
| Assistant/tool results | Dimmed (selectable, for context continuation) |
|
||||
| Compaction nodes | `[compaction: Xk tokens]` |
|
||||
| Branch points | Node with multiple children visible |
|
||||
|
||||
### Navigation
|
||||
### Controls
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| ↑/↓ | Move through nodes (depth-first pre-order) |
|
||||
| Enter | Select node and proceed |
|
||||
| Escape | Cancel |
|
||||
| Ctrl+C | Cancel |
|
||||
| Ctrl+U | Toggle: show only user messages |
|
||||
| Ctrl+O | Toggle: show all entries (including custom/label) |
|
||||
| ↑/↓ | Navigate (depth-first order) |
|
||||
| Enter | Select node |
|
||||
| Escape/Ctrl+C | Cancel |
|
||||
| Ctrl+U | Toggle: user messages only |
|
||||
| Ctrl+O | Toggle: show all (including custom/label entries) |
|
||||
|
||||
### Filtering
|
||||
### Display
|
||||
|
||||
Default view hides:
|
||||
- `label` entries (labels shown inline on their target node)
|
||||
- `custom` entries (hook state, not relevant for navigation)
|
||||
|
||||
Ctrl+O shows everything for debugging/inspection.
|
||||
|
||||
### Component Size
|
||||
|
||||
Height is capped at **half terminal height** to show substantial tree context without overshooting the terminal.
|
||||
- Height: half terminal height
|
||||
- Current leaf marked with `← active`
|
||||
- Labels shown inline: `[label-name]`
|
||||
- Default filter hides `label` and `custom` entries (shown in Ctrl+O mode)
|
||||
- Children sorted by timestamp (oldest first)
|
||||
|
||||
## Selection Behavior
|
||||
|
||||
### Selecting Current Active Leaf
|
||||
### User Message or Custom Message
|
||||
1. Leaf set to **parent** of selected node (or `null` if root)
|
||||
2. Message text placed in **editor** for re-submission
|
||||
3. User edits and submits, creating a new branch
|
||||
|
||||
No-op. Display message: "Already at this point."
|
||||
### Non-User Message (assistant, compaction, etc.)
|
||||
1. Leaf set to **selected node**
|
||||
2. Editor stays empty
|
||||
3. User continues from that point
|
||||
|
||||
### Switching to Different Node
|
||||
|
||||
### User Message or Custom Message Selected
|
||||
1. Active leaf is set to **parent** of selected node
|
||||
2. Selected message text is placed in the **editor** for re-submission
|
||||
3. User edits and submits, creating a new branch from that point
|
||||
|
||||
### Non-User Message Selected (assistant, tool result, etc.)
|
||||
1. Active leaf is set to the **selected node itself**
|
||||
2. Editor remains empty
|
||||
3. User continues the conversation from that point
|
||||
### Selecting Root User Message
|
||||
If user selects the very first message (has no parent):
|
||||
1. Leaf reset to `null` (empty conversation)
|
||||
2. Message text placed in editor
|
||||
3. User effectively restarts from scratch
|
||||
|
||||
## Branch Summarization
|
||||
|
||||
When switching branches, the user is prompted: "Summarize the branch you're leaving?"
|
||||
When switching, user is prompted: "Summarize the branch you're leaving?"
|
||||
|
||||
### What Gets Summarized
|
||||
|
||||
The abandoned branch is the path from the **old active leaf** back to the **common ancestor** of the old leaf and newly selected node.
|
||||
Path from old leaf back to common ancestor with target:
|
||||
|
||||
```
|
||||
A → B → C → D → E → F ← old active leaf
|
||||
↘ G → H ← user selects H
|
||||
A → B → C → D → E → F ← old leaf
|
||||
↘ G → H ← target
|
||||
```
|
||||
|
||||
- Common ancestor: C
|
||||
- Abandoned path: D → E → F
|
||||
- These nodes are summarized
|
||||
Abandoned path: D → E → F (summarized)
|
||||
|
||||
### Stopping Conditions
|
||||
|
||||
When walking back from the old leaf to gather content for summarization:
|
||||
|
||||
1. **Stop at common ancestor** (always)
|
||||
2. **Stop at compaction node** (if encountered before common ancestor)
|
||||
- Compaction already summarizes older content
|
||||
- Only summarize "fresh" content after the compaction
|
||||
Summarization stops at:
|
||||
1. Common ancestor (always)
|
||||
2. Compaction node (if encountered first)
|
||||
|
||||
### Summary Storage
|
||||
|
||||
The summary is stored as a `BranchSummaryEntry`:
|
||||
Stored as `BranchSummaryEntry`:
|
||||
|
||||
```typescript
|
||||
interface BranchSummaryEntry {
|
||||
type: "branch_summary";
|
||||
id: string;
|
||||
parentId: string; // Points to common ancestor
|
||||
parentId: string; // New leaf position
|
||||
timestamp: string;
|
||||
fromId: string; // The old leaf we abandoned
|
||||
fromId: string; // Old leaf we abandoned
|
||||
summary: string; // LLM-generated summary
|
||||
details?: unknown; // Optional hook data
|
||||
}
|
||||
```
|
||||
|
||||
The summary entry becomes a sibling of the path we're switching to, preserving the record of what was abandoned.
|
||||
## Implementation
|
||||
|
||||
### Summary Generation
|
||||
|
||||
The summarizer:
|
||||
1. Collects messages from old leaf back to stopping point
|
||||
2. Sends to LLM with prompt: "Summarize this conversation branch concisely"
|
||||
3. Creates `BranchSummaryEntry` with the result
|
||||
|
||||
User can skip summarization, in which case no `BranchSummaryEntry` is created.
|
||||
|
||||
## Example Flow
|
||||
|
||||
```
|
||||
Initial state:
|
||||
A → B → C → D ← active
|
||||
|
||||
User runs /tree, selects B:
|
||||
|
||||
1. Show tree:
|
||||
├─ A (user): "Start task..."
|
||||
│ └─ B (assistant): "I'll help..."
|
||||
│ └─ C (user): "Do X..."
|
||||
│ └─ D (assistant): "Done X..." ← active
|
||||
|
||||
2. User navigates to B, presses Enter
|
||||
|
||||
3. Prompt: "Summarize branch you're leaving? [Y/n]"
|
||||
|
||||
4a. If Yes:
|
||||
- Summarize C → D
|
||||
- Create BranchSummaryEntry(fromId: D, summary: "...")
|
||||
- Set active leaf to B
|
||||
- Tree becomes:
|
||||
A → B → C → D
|
||||
↓ ↘ [summary: "Tried X..."]
|
||||
└─ (active, user continues from here)
|
||||
|
||||
4b. If No:
|
||||
- Set active leaf to B
|
||||
- No summary entry created
|
||||
|
||||
5. Since B is assistant message:
|
||||
- Editor stays empty
|
||||
- User types new message, branches from B
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### SessionManager Methods (already exist)
|
||||
|
||||
- `getTree()` - Get full tree structure for display (needs: sort children by timestamp)
|
||||
- `getPath(id)` - Get path from root to any node
|
||||
- `getEntry(id)` - Look up individual entries
|
||||
- `getLeafUuid()` - Get current active leaf
|
||||
- `branch(id)` - Change active leaf
|
||||
- `branchWithSummary(fromId, summary)` - Create branch summary entry
|
||||
- `buildSessionContext()` - Get messages for LLM from current leaf
|
||||
|
||||
### AgentSession: New `navigateTree()` Method
|
||||
### AgentSession.navigateTree()
|
||||
|
||||
```typescript
|
||||
interface NavigateTreeOptions {
|
||||
/** Whether user wants to summarize abandoned branch */
|
||||
summarize?: boolean;
|
||||
/** Custom instructions for summarizer */
|
||||
customInstructions?: string;
|
||||
}
|
||||
|
||||
interface NavigateTreeResult {
|
||||
/** Text to put in editor (if user message selected) */
|
||||
editorText?: string;
|
||||
/** Whether navigation was cancelled */
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
async navigateTree(targetId: string, options?: NavigateTreeOptions): Promise<NavigateTreeResult>
|
||||
async navigateTree(
|
||||
targetId: string,
|
||||
options?: { summarize?: boolean; customInstructions?: string }
|
||||
): Promise<{ editorText?: string; cancelled: boolean }>
|
||||
```
|
||||
|
||||
Implementation flow:
|
||||
Flow:
|
||||
1. Validate target, check no-op (target === current leaf)
|
||||
2. Find common ancestor between old leaf and target
|
||||
3. Collect entries to summarize (if requested)
|
||||
4. Fire `session_before_tree` event (hook can cancel or provide summary)
|
||||
5. Run default summarizer if needed
|
||||
6. Switch leaf via `branch()` or `branchWithSummary()`
|
||||
7. Update agent: `agent.replaceMessages(sessionManager.buildSessionContext().messages)`
|
||||
8. Fire `session_tree` event
|
||||
9. Notify custom tools via session event
|
||||
10. Return result with `editorText` if user message was selected
|
||||
|
||||
1. **Validate target exists**
|
||||
2. **Check if no-op** (target === current leaf) → return early
|
||||
3. **Prepare summarization** (if `options.summarize`):
|
||||
- Find common ancestor
|
||||
- Collect entries to summarize (old leaf → common ancestor, stop at compaction)
|
||||
4. **Fire `session_before_tree` event**:
|
||||
- Pass preparation, model, signal
|
||||
- If hook returns `cancel: true` → return `{ cancelled: true }`
|
||||
- If hook returns custom summary → use it, skip default summarizer
|
||||
5. **Run default summarizer** (if needed):
|
||||
- Use conversation model
|
||||
- On failure/abort → return `{ cancelled: true }`
|
||||
6. **Switch leaf**:
|
||||
- If summarizing: `sessionManager.branchWithSummary(targetId, summary)`
|
||||
- Otherwise: `sessionManager.branch(targetId)`
|
||||
7. **Update agent state**:
|
||||
```typescript
|
||||
const context = this.sessionManager.buildSessionContext();
|
||||
this.agent.replaceMessages(context.messages);
|
||||
```
|
||||
8. **Fire `session_tree` event**
|
||||
9. **Notify custom tools** via `_emitToolSessionEvent("tree", ...)`
|
||||
10. **Return result**:
|
||||
- If target was user message: `{ editorText: messageText, cancelled: false }`
|
||||
- Otherwise: `{ cancelled: false }`
|
||||
### SessionManager
|
||||
|
||||
### InteractiveMode: `/tree` Command Handler
|
||||
- `getLeafUuid(): string | null` - Current leaf (null if empty)
|
||||
- `resetLeaf(): void` - Set leaf to null (for root user message navigation)
|
||||
- `getTree(): SessionTreeNode[]` - Full tree with children sorted by timestamp
|
||||
- `branch(id)` - Change leaf pointer
|
||||
- `branchWithSummary(id, summary)` - Change leaf and create summary entry
|
||||
|
||||
```typescript
|
||||
if (text === "/tree") {
|
||||
this.showTreeSelector();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
```
|
||||
### InteractiveMode
|
||||
|
||||
`showTreeSelector()` flow:
|
||||
|
||||
1. Get tree via `sessionManager.getTree()`
|
||||
2. Show `TreeSelectorComponent` (new component)
|
||||
3. On selection:
|
||||
- If target === current leaf → show "Already at this point", done
|
||||
- Prompt: "Summarize branch you're leaving? [Y/n]"
|
||||
- Call `session.navigateTree(targetId, { summarize })`
|
||||
- If cancelled → done
|
||||
- Clear chat: `this.chatContainer.clear()`
|
||||
- Re-render: `this.renderInitialMessages()`
|
||||
- If `result.editorText` → `this.editor.setText(result.editorText)`
|
||||
- Show status: "Switched to entry X"
|
||||
|
||||
### TUI Update Flow
|
||||
|
||||
After `navigateTree()` completes successfully:
|
||||
|
||||
```typescript
|
||||
// In InteractiveMode, after navigateTree returns
|
||||
if (!result.cancelled) {
|
||||
this.chatContainer.clear();
|
||||
this.renderInitialMessages(); // Uses sessionManager.buildSessionContext()
|
||||
if (result.editorText) {
|
||||
this.editor.setText(result.editorText);
|
||||
}
|
||||
this.showStatus("Navigated to selected point");
|
||||
}
|
||||
```
|
||||
|
||||
This matches the existing pattern in `handleResumeSession()` and `handleClearCommand()`.
|
||||
|
||||
### Finding Common Ancestor
|
||||
|
||||
```typescript
|
||||
function findCommonAncestor(nodeA: string, nodeB: string): string {
|
||||
const pathA = new Set(sessionManager.getPath(nodeA).map(e => e.id));
|
||||
for (const entry of sessionManager.getPath(nodeB)) {
|
||||
if (pathA.has(entry.id)) {
|
||||
return entry.id;
|
||||
}
|
||||
}
|
||||
throw new Error("No common ancestor found");
|
||||
}
|
||||
```
|
||||
|
||||
### Collecting Abandoned Branch
|
||||
|
||||
```typescript
|
||||
function collectAbandonedBranch(oldLeaf: string, commonAncestor: string): SessionEntry[] {
|
||||
const entries: SessionEntry[] = [];
|
||||
let current = oldLeaf;
|
||||
|
||||
while (current !== commonAncestor) {
|
||||
const entry = sessionManager.getEntry(current);
|
||||
if (!entry) break;
|
||||
|
||||
// Stop at compaction - older content already summarized
|
||||
if (entry.type === "compaction") break;
|
||||
|
||||
entries.push(entry);
|
||||
current = entry.parentId;
|
||||
}
|
||||
|
||||
return entries.reverse(); // Chronological order
|
||||
}
|
||||
```
|
||||
|
||||
### Tree Child Ordering
|
||||
|
||||
`getTree()` should sort children by timestamp (oldest first, newest at bottom):
|
||||
|
||||
```typescript
|
||||
// In getTree(), after building tree:
|
||||
function sortChildren(node: SessionTreeNode): void {
|
||||
node.children.sort((a, b) =>
|
||||
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
|
||||
);
|
||||
node.children.forEach(sortChildren);
|
||||
}
|
||||
roots.forEach(sortChildren);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
**Summarization fails** (API error, timeout, etc.):
|
||||
- Cancel the entire switch
|
||||
- Show error message
|
||||
- User stays at current position
|
||||
|
||||
**User aborts during summarization** (Escape):
|
||||
- Cancel the entire switch
|
||||
- Show "Navigation cancelled"
|
||||
- User stays at current position
|
||||
|
||||
**Hook returns `cancel: true`**:
|
||||
- Cancel the switch
|
||||
- No error message (hook may have shown its own UI)
|
||||
- User stays at current position
|
||||
|
||||
### TreeSelectorComponent
|
||||
|
||||
New TUI component at `src/modes/interactive/components/tree-selector.ts`:
|
||||
|
||||
```typescript
|
||||
interface TreeSelectorProps {
|
||||
tree: SessionTreeNode[];
|
||||
currentLeafId: string;
|
||||
onSelect: (entryId: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
Features:
|
||||
- Height: half terminal height (capped)
|
||||
- ASCII tree rendering with `├─`, `│`, `└─` connectors
|
||||
- Depth-first traversal for up/down navigation
|
||||
- Visual indicators:
|
||||
- `← active` for current leaf
|
||||
- Resolved labels shown inline
|
||||
- Compaction nodes as `[compaction: Xk tokens]`
|
||||
- Filter modes:
|
||||
- Default: hide `label` and `custom` entries
|
||||
- Ctrl+U: user messages only
|
||||
- Ctrl+O: show all entries
|
||||
- Scrolling with selected node kept visible
|
||||
`/tree` command shows `TreeSelectorComponent`, then:
|
||||
1. Prompt for summarization
|
||||
2. Call `session.navigateTree()`
|
||||
3. Clear and re-render chat
|
||||
4. Set editor text if applicable
|
||||
|
||||
## Hook Events
|
||||
|
||||
These events are separate from `session_before_branch`/`session_branch` which are used by the existing `/branch` command (creates new session file).
|
||||
|
||||
### `session_before_tree`
|
||||
|
||||
Fired before switching branches within the same session file. Hooks can cancel or provide custom summary.
|
||||
|
||||
```typescript
|
||||
interface TreePreparation {
|
||||
/** Node being switched to */
|
||||
targetId: string;
|
||||
/** Current active leaf (being abandoned) */
|
||||
oldLeafId: string;
|
||||
/** Common ancestor of target and old leaf */
|
||||
commonAncestorId: string;
|
||||
/** Entries to summarize (old leaf back to common ancestor or compaction) */
|
||||
oldLeafId: string | null;
|
||||
commonAncestorId: string | null;
|
||||
entriesToSummarize: SessionEntry[];
|
||||
/** Whether user chose to summarize */
|
||||
userWantsSummary: boolean;
|
||||
}
|
||||
|
||||
interface SessionBeforeTreeEvent {
|
||||
type: "session_before_tree";
|
||||
preparation: TreePreparation;
|
||||
/** Model to use for summarization (conversation model) */
|
||||
model: Model;
|
||||
/** Abort signal - honors Escape during summarization */
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
interface SessionBeforeTreeResult {
|
||||
/** Cancel the navigation entirely */
|
||||
cancel?: boolean;
|
||||
/** Custom summary (skips default summarizer). Only used if userWantsSummary is true. */
|
||||
summary?: {
|
||||
summary: string;
|
||||
details?: unknown;
|
||||
};
|
||||
summary?: { summary: string; details?: unknown };
|
||||
}
|
||||
```
|
||||
|
||||
### `session_tree`
|
||||
|
||||
Fired after navigation completes successfully. Not fired if cancelled.
|
||||
|
||||
```typescript
|
||||
interface SessionTreeEvent {
|
||||
type: "session_tree";
|
||||
/** The new active leaf */
|
||||
newLeafId: string;
|
||||
/** Previous active leaf */
|
||||
oldLeafId: string;
|
||||
/** Branch summary entry if one was created, undefined if user skipped summarization */
|
||||
newLeafId: string | null;
|
||||
oldLeafId: string | null;
|
||||
summaryEntry?: BranchSummaryEntry;
|
||||
/** Whether summary came from hook (false if default summarizer used, undefined if no summary) */
|
||||
fromHook?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Custom Branch Summarizer
|
||||
### Example: Custom Summarizer
|
||||
|
||||
```typescript
|
||||
export default function(pi: HookAPI) {
|
||||
|
|
@ -447,23 +184,14 @@ export default function(pi: HookAPI) {
|
|||
if (!event.preparation.userWantsSummary) return;
|
||||
if (event.preparation.entriesToSummarize.length === 0) return;
|
||||
|
||||
// Use a different model for summarization
|
||||
const model = getModel("google", "gemini-2.5-flash");
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
||||
|
||||
// Custom summarization logic
|
||||
const summary = await summarizeWithCustomPrompt(
|
||||
event.preparation.entriesToSummarize,
|
||||
model,
|
||||
apiKey
|
||||
);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
summary,
|
||||
details: { model: model.id, timestamp: Date.now() }
|
||||
}
|
||||
};
|
||||
const summary = await myCustomSummarizer(event.preparation.entriesToSummarize);
|
||||
return { summary: { summary, details: { custom: true } } };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Summarization failure: cancels navigation, shows error
|
||||
- User abort (Escape): cancels navigation
|
||||
- Hook returns `cancel: true`: cancels navigation silently
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue