co-mono/packages/coding-agent/docs/tree.md
Mario Zechner 59ce62bffc Tree spec
2025-12-30 22:42:22 +01:00

14 KiB

Branch Summary

This document describes the /tree command and branch summarization feature.

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

This differs from /branch which extracts a linear path to a new session file.

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

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: "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

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)

Filtering

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.

Selection Behavior

Selecting Current Active Leaf

No-op. Display message: "Already at this 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

Branch Summarization

When switching branches, the 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.

A → B → C → D → E → F  ← old active leaf
        ↘ G → H        ← user selects H
  • Common ancestor: C
  • Abandoned path: D → E → F
  • These nodes are 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

Summary Storage

The summary is stored as a BranchSummaryEntry:

interface BranchSummaryEntry {
  type: "branch_summary";
  id: string;
  parentId: string;      // Points to common ancestor
  timestamp: string;
  fromId: string;        // The 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.

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

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>

Implementation flow:

  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:
    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

if (text === "/tree") {
  this.showTreeSelector();
  this.editor.setText("");
  return;
}

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.editorTextthis.editor.setText(result.editorText)
    • Show status: "Switched to entry X"

TUI Update Flow

After navigateTree() completes successfully:

// 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

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

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):

// 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:

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

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.

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) */
  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;
  };
}

session_tree

Fired after navigation completes successfully. Not fired if cancelled.

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 */
  summaryEntry?: BranchSummaryEntry;
  /** Whether summary came from hook (false if default summarizer used, undefined if no summary) */
  fromHook?: boolean;
}

Example: Custom Branch Summarizer

export default function(pi: HookAPI) {
  pi.on("session_before_tree", async (event, ctx) => {
    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() }
      }
    };
  });
}