Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
40 kB · 1130 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131import { type Component, Container, type Focusable, getKeybindings, Input, Spacer, Text, TruncatedText, truncateToWidth,} from "@earendil-works/pi-tui";import type { AgentConnectionSessionTreeNode } from "../../agent-connection/index.js";import { theme } from "../theme/theme.js";import { DynamicBorder } from "./dynamic-border.js";import { keyHint, keyText } from "./keybinding-hints.js";
/** Gutter info: position (displayIndent where connector was) and whether to show │ */interface GutterInfo { position: number; // displayIndent level where the connector was shown show: boolean; // true = show │, false = show spaces}
/** Flattened tree node for navigation */interface FlatNode { node: AgentConnectionSessionTreeNode; /** Indentation level (each level = 3 chars) */ indent: number; /** Whether to show connector (├─ or └─) - true if parent has multiple children */ showConnector: boolean; /** If showConnector, true = last sibling (└─), false = not last (├─) */ isLast: boolean; /** Gutter info for each ancestor branch point */ gutters: GutterInfo[]; /** True if this node is a root under a virtual branching root (multiple roots) */ isVirtualRootChild: boolean;}
/** Filter mode for tree display */export type FilterMode = "default" | "no-tools" | "user-only" | "labeled-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"; private searchQuery = ""; /** Tool call display strings by call id, so a toolResult line shows its call. */ private toolCallDisplays: Map<string, string> = new Map(); private multipleRoots = false; private showLabelTimestamps = false; private activePathIds: Set<string> = new Set(); private visibleParentMap: Map<string, string | null> = new Map(); private visibleChildrenMap: Map<string | null, string[]> = new Map(); private lastSelectedId: string | null = null; private foldedNodes: Set<string> = new Set();
public onSelect?: (entryId: string) => void; public onCancel?: () => void; public onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void;
constructor( tree: AgentConnectionSessionTreeNode[], currentLeafId: string | null, maxVisibleLines: number, initialSelectedId?: string, initialFilterMode?: FilterMode, ) { this.currentLeafId = currentLeafId; this.maxVisibleLines = maxVisibleLines; this.filterMode = initialFilterMode ?? "default"; this.multipleRoots = tree.length > 1; this.flatNodes = this.flattenTree(tree); this.buildActivePath(); this.applyFilter();
// Start with initialSelectedId if provided, otherwise current leaf const targetId = initialSelectedId ?? currentLeafId; this.selectedIndex = this.findNearestVisibleIndex(targetId); this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? null; }
/** * Find the index of the nearest visible entry, walking up the parent chain if needed. * Returns the index in filteredNodes, or the last index as fallback. */ private findNearestVisibleIndex(entryId: string | null): number { if (this.filteredNodes.length === 0) return 0;
// Build a map for parent lookup const entryMap = new Map<string, FlatNode>(); for (const flatNode of this.flatNodes) { entryMap.set(flatNode.node.entry.id, flatNode); }
// Build a map of visible entry IDs to their indices in filteredNodes const visibleIdToIndex = new Map<string, number>(this.filteredNodes.map((node, i) => [node.node.entry.id, i]));
// Walk from entryId up to root, looking for a visible entry let currentId = entryId; while (currentId !== null) { const index = visibleIdToIndex.get(currentId); if (index !== undefined) return index; const node = entryMap.get(currentId); if (!node) break; currentId = node.node.entry.parentId ?? null; }
// Fallback: last visible entry return this.filteredNodes.length - 1; }
/** Build the set of entry IDs on the path from root to current leaf */ private buildActivePath(): void { this.activePathIds.clear(); if (!this.currentLeafId) return;
// Build a map of id -> entry for parent lookup const entryMap = new Map<string, FlatNode>(); for (const flatNode of this.flatNodes) { entryMap.set(flatNode.node.entry.id, flatNode); }
// Walk from leaf to root let currentId: string | null = this.currentLeafId; while (currentId) { this.activePathIds.add(currentId); const node = entryMap.get(currentId); if (!node) break; currentId = node.node.entry.parentId ?? null; } }
private flattenTree(roots: AgentConnectionSessionTreeNode[]): FlatNode[] { const result: FlatNode[] = []; this.toolCallDisplays.clear();
// Indentation rules: // - At indent 0: stay at 0 unless parent has >1 children (then +1) // - At indent 1: children always go to indent 2 (visual grouping of subtree) // - At indent 2+: stay flat for single-child chains, +1 only if parent branches
// Stack items: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] type StackItem = [AgentConnectionSessionTreeNode, number, boolean, boolean, boolean, GutterInfo[], boolean]; const stack: StackItem[] = [];
// Determine which subtrees contain the active leaf (to sort current branch first) // Use iterative post-order traversal to avoid stack overflow const containsActive = new Map<AgentConnectionSessionTreeNode, boolean>(); const leafId = this.currentLeafId; { // Build list in pre-order, then process in reverse for post-order effect const allNodes: AgentConnectionSessionTreeNode[] = []; const preOrderStack: AgentConnectionSessionTreeNode[] = [...roots]; while (preOrderStack.length > 0) { const node = preOrderStack.pop()!; allNodes.push(node); // Push children in reverse so they're processed left-to-right for (let i = node.children.length - 1; i >= 0; i--) { preOrderStack.push(node.children[i]); } } // Process in reverse (post-order): children before parents for (let i = allNodes.length - 1; i >= 0; i--) { const node = allNodes[i]; let has = leafId !== null && node.entry.id === leafId; for (const child of node.children) { if (containsActive.get(child)) { has = true; } } containsActive.set(node, has); } }
// Add roots in reverse order, prioritizing the one containing the active leaf // If multiple roots, treat them as children of a virtual root that branches const multipleRoots = roots.length > 1; const orderedRoots = [...roots].sort((a, b) => Number(containsActive.get(b)) - Number(containsActive.get(a))); for (let i = orderedRoots.length - 1; i >= 0; i--) { const isLast = i === orderedRoots.length - 1; stack.push([orderedRoots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]); }
while (stack.length > 0) { const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop()!;
const entry = node.entry; if (entry.type === "message") { for (const call of entry.toolCalls ?? []) { this.toolCallDisplays.set(call.id, call.display); } }
result.push({ node, indent, showConnector, isLast, gutters, isVirtualRootChild });
const children = node.children; const multipleChildren = children.length > 1;
// Order children so the branch containing the active leaf comes first const orderedChildren = (() => { const prioritized: AgentConnectionSessionTreeNode[] = []; const rest: AgentConnectionSessionTreeNode[] = []; for (const child of children) { if (containsActive.get(child)) { prioritized.push(child); } else { rest.push(child); } } return [...prioritized, ...rest]; })();
// Calculate child indent let childIndent: number; if (multipleChildren) { // Parent branches: children get +1 childIndent = indent + 1; } else if (justBranched && indent > 0) { // First generation after a branch: +1 for visual grouping childIndent = indent + 1; } else { // Single-child chain: stay flat childIndent = indent; }
// Build gutters for children // If this node showed a connector, add a gutter entry for descendants // Only add gutter if connector is actually displayed (not suppressed for virtual root children) const connectorDisplayed = showConnector && !isVirtualRootChild; // When connector is displayed, add a gutter entry at the connector's position // Connector is at position (displayIndent - 1), so gutter should be there too const currentDisplayIndent = this.multipleRoots ? Math.max(0, indent - 1) : indent; const connectorPosition = Math.max(0, currentDisplayIndent - 1); const childGutters: GutterInfo[] = connectorDisplayed ? [...gutters, { position: connectorPosition, show: !isLast }] : gutters;
// Add children in reverse order for (let i = orderedChildren.length - 1; i >= 0; i--) { const childIsLast = i === orderedChildren.length - 1; stack.push([ orderedChildren[i], childIndent, multipleChildren, multipleChildren, childIsLast, childGutters, false, ]); } }
return result; }
private applyFilter(): void { // Update lastSelectedId only when we have a valid selection (non-empty list) // This preserves the selection when switching through empty filter results if (this.filteredNodes.length > 0) { this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId; }
const searchTokens = this.searchQuery.toLowerCase().split(/\s+/).filter(Boolean);
this.filteredNodes = this.flatNodes.filter((flatNode) => { const entry = flatNode.node.entry; const isCurrentLeaf = entry.id === this.currentLeafId;
// Skip assistant messages with only tool calls (no text) unless error/aborted // Always show current leaf so active position is visible if (entry.type === "message" && entry.role === "assistant" && !isCurrentLeaf) { const isErrorOrAborted = entry.stopReason !== undefined && entry.stopReason !== "stop" && entry.stopReason !== "toolUse"; // Only hide if no text AND not an error/aborted message if (!entry.hasText && !isErrorOrAborted) { return false; } }
// Apply filter mode let passesFilter = true; // Entry types hidden in default view (settings/bookkeeping) const isSettingsEntry = entry.type === "label" || entry.type === "custom" || entry.type === "model_change" || entry.type === "thinking_level_change" || entry.type === "service_tier_change" || entry.type === "session_info" || entry.type === "child_usage_attributed";
switch (this.filterMode) { case "user-only": // Just user messages passesFilter = entry.type === "message" && entry.role === "user"; break; case "no-tools": // Default minus tool results passesFilter = !isSettingsEntry && !(entry.type === "message" && entry.role === "toolResult"); break; case "labeled-only": // Just labeled entries passesFilter = flatNode.node.label !== undefined; break; case "all": // Show everything passesFilter = true; break; default: // Default mode: hide settings/bookkeeping entries passesFilter = !isSettingsEntry; break; }
if (!passesFilter) return false;
// Apply search filter if (searchTokens.length > 0) { const nodeText = this.getSearchableText(flatNode.node).toLowerCase(); return searchTokens.every((token) => nodeText.includes(token)); }
return true; });
// Filter out descendants of folded nodes. if (this.foldedNodes.size > 0) { const skipSet = new Set<string>(); for (const flatNode of this.flatNodes) { const { id, parentId } = flatNode.node.entry; if (parentId != null && (this.foldedNodes.has(parentId) || skipSet.has(parentId))) { skipSet.add(id); } } this.filteredNodes = this.filteredNodes.filter((flatNode) => !skipSet.has(flatNode.node.entry.id)); }
// Recalculate visual structure (indent, connectors, gutters) based on visible tree this.recalculateVisualStructure();
// Try to preserve cursor on the same node, or find nearest visible ancestor if (this.lastSelectedId) { this.selectedIndex = this.findNearestVisibleIndex(this.lastSelectedId); } else if (this.selectedIndex >= this.filteredNodes.length) { // Clamp index if out of bounds this.selectedIndex = Math.max(0, this.filteredNodes.length - 1); }
// Update lastSelectedId to the actual selection (may have changed due to parent walk) if (this.filteredNodes.length > 0) { this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId; } }
/** * Recompute indentation/connectors for the filtered view * * Filtering can hide intermediate entries; descendants attach to the nearest visible ancestor. * Keep indentation semantics aligned with flattenTree() so single-child chains don't drift right. */ private recalculateVisualStructure(): void { if (this.filteredNodes.length === 0) return;
const visibleIds = new Set(this.filteredNodes.map((n) => n.node.entry.id));
// Build entry map for efficient parent lookup (using full tree) const entryMap = new Map<string, FlatNode>(); for (const flatNode of this.flatNodes) { entryMap.set(flatNode.node.entry.id, flatNode); }
// Find nearest visible ancestor for a node const findVisibleAncestor = (nodeId: string): string | null => { let currentId = entryMap.get(nodeId)?.node.entry.parentId ?? null; while (currentId !== null) { if (visibleIds.has(currentId)) { return currentId; } currentId = entryMap.get(currentId)?.node.entry.parentId ?? null; } return null; };
// Build visible tree structure: // - visibleParent: nodeId → nearest visible ancestor (or null for roots) // - visibleChildren: parentId → list of visible children (in filteredNodes order) const visibleParent = new Map<string, string | null>(); const visibleChildren = new Map<string | null, string[]>(); visibleChildren.set(null, []); // root-level nodes
for (const flatNode of this.filteredNodes) { const nodeId = flatNode.node.entry.id; const ancestorId = findVisibleAncestor(nodeId); visibleParent.set(nodeId, ancestorId);
if (!visibleChildren.has(ancestorId)) { visibleChildren.set(ancestorId, []); } visibleChildren.get(ancestorId)!.push(nodeId); }
// Update multipleRoots based on visible roots const visibleRootIds = visibleChildren.get(null)!; this.multipleRoots = visibleRootIds.length > 1;
// Build a map for quick lookup: nodeId → FlatNode const filteredNodeMap = new Map<string, FlatNode>(); for (const flatNode of this.filteredNodes) { filteredNodeMap.set(flatNode.node.entry.id, flatNode); }
// DFS over the visible tree using flattenTree() indentation semantics // Stack items: [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] type StackItem = [string, number, boolean, boolean, boolean, GutterInfo[], boolean]; const stack: StackItem[] = [];
// Add visible roots in reverse order (to process in forward order via stack) for (let i = visibleRootIds.length - 1; i >= 0; i--) { const isLast = i === visibleRootIds.length - 1; stack.push([ visibleRootIds[i], this.multipleRoots ? 1 : 0, this.multipleRoots, this.multipleRoots, isLast, [], this.multipleRoots, ]); }
while (stack.length > 0) { const [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop()!;
const flatNode = filteredNodeMap.get(nodeId); if (!flatNode) continue;
// Update this node's visual properties flatNode.indent = indent; flatNode.showConnector = showConnector; flatNode.isLast = isLast; flatNode.gutters = gutters; flatNode.isVirtualRootChild = isVirtualRootChild;
// Get visible children of this node const children = visibleChildren.get(nodeId) || []; const multipleChildren = children.length > 1;
// Child indent follows flattenTree(): branch points (and first generation after a branch) shift +1 let childIndent: number; if (multipleChildren) { childIndent = indent + 1; } else if (justBranched && indent > 0) { childIndent = indent + 1; } else { childIndent = indent; }
// Child gutters follow flattenTree() connector/gutter rules const connectorDisplayed = showConnector && !isVirtualRootChild; const currentDisplayIndent = this.multipleRoots ? Math.max(0, indent - 1) : indent; const connectorPosition = Math.max(0, currentDisplayIndent - 1); const childGutters: GutterInfo[] = connectorDisplayed ? [...gutters, { position: connectorPosition, show: !isLast }] : gutters;
// Add children in reverse order (to process in forward order via stack) for (let i = children.length - 1; i >= 0; i--) { const childIsLast = i === children.length - 1; stack.push([ children[i], childIndent, multipleChildren, multipleChildren, childIsLast, childGutters, false, ]); } }
// Store visible tree maps for ancestor/descendant lookups in navigation this.visibleParentMap = visibleParent; this.visibleChildrenMap = visibleChildren; }
/** Get searchable text content from a node */ private getSearchableText(node: AgentConnectionSessionTreeNode): string { const entry = node.entry; const parts: string[] = [];
if (node.label) { parts.push(node.label); }
switch (entry.type) { case "message": { parts.push(entry.role); // Covers user/assistant text, a bash command, and the tool result text // the tree has always matched search tokens against. if (entry.text) parts.push(entry.text); break; } case "custom_message": parts.push(entry.customType, entry.text); break; case "compaction": parts.push("compaction"); break; case "branch_summary": parts.push("branch summary", entry.text); break; case "session_info": parts.push("title"); if (entry.name) parts.push(entry.name); break; case "model_change": parts.push("model", entry.modelId); break; case "thinking_level_change": parts.push("thinking", entry.thinkingLevel); break; case "service_tier_change": parts.push("service tier", entry.serviceTier); if (entry.serviceTier === "priority") parts.push("fast on"); break; case "custom": parts.push("custom", entry.customType); break; case "child_usage_attributed": parts.push("child usage", entry.targetId); break; case "label": parts.push("label", entry.label ?? ""); break; }
return parts.join(" "); }
invalidate(): void {}
getSearchQuery(): string { return this.searchQuery; }
getSelectedNode(): AgentConnectionSessionTreeNode | undefined { return this.filteredNodes[this.selectedIndex]?.node; }
updateNodeLabel(entryId: string, label: string | undefined, labelTimestamp?: string): void { for (const flatNode of this.flatNodes) { if (flatNode.node.entry.id === entryId) { flatNode.node.label = label; flatNode.node.labelTimestamp = label ? (labelTimestamp ?? new Date().toISOString()) : undefined; break; } } }
private getStatusLabels(): string { let labels = ""; switch (this.filterMode) { case "no-tools": labels += " [no-tools]"; break; case "user-only": labels += " [user]"; break; case "labeled-only": labels += " [labeled]"; break; case "all": labels += " [all]"; break; } if (this.showLabelTimestamps) { labels += " [+label time]"; } return labels; }
render(width: number): string[] { const lines: string[] = [];
if (this.filteredNodes.length === 0) { lines.push(truncateToWidth(theme.fg("muted", " No entries found"), width)); lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.getStatusLabels()}`), width)); return lines; }
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;
// Build line: cursor + prefix + path marker + label + content const cursor = isSelected ? theme.fg("accent", "› ") : " ";
// If multiple roots, shift display (roots at 0, not 1) const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent;
// Build prefix with gutters at their correct positions // Each gutter has a position (displayIndent where its connector was shown) const connector = flatNode.showConnector && !flatNode.isVirtualRootChild ? (flatNode.isLast ? "└─ " : "├─ ") : ""; const connectorPosition = connector ? displayIndent - 1 : -1;
// Build prefix char by char, placing gutters and connector at their positions const totalChars = displayIndent * 3; const prefixChars: string[] = []; const isFolded = this.foldedNodes.has(entry.id); for (let i = 0; i < totalChars; i++) { const level = Math.floor(i / 3); const posInLevel = i % 3;
// Check if there's a gutter at this level const gutter = flatNode.gutters.find((g) => g.position === level); if (gutter) { if (posInLevel === 0) { prefixChars.push(gutter.show ? "│" : " "); } else { prefixChars.push(" "); } } else if (connector && level === connectorPosition) { // Connector at this level, with fold indicator if (posInLevel === 0) { prefixChars.push(flatNode.isLast ? "└" : "├"); } else if (posInLevel === 1) { const foldable = this.isFoldable(entry.id); prefixChars.push(isFolded ? "⊞" : foldable ? "⊟" : "─"); } else { prefixChars.push(" "); } } else { prefixChars.push(" "); } } const prefix = prefixChars.join("");
// Fold marker for nodes without connectors (roots) const showsFoldInConnector = flatNode.showConnector && !flatNode.isVirtualRootChild; const foldMarker = isFolded && !showsFoldInConnector ? theme.fg("accent", "⊞ ") : "";
// Active path marker - shown right before the entry text const isOnActivePath = this.activePathIds.has(entry.id); const pathMarker = isOnActivePath ? theme.fg("accent", "• ") : "";
const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; const labelTimestamp = this.showLabelTimestamps && flatNode.node.label && flatNode.node.labelTimestamp ? theme.fg("muted", `${this.formatLabelTimestamp(flatNode.node.labelTimestamp)} `) : ""; const content = this.getEntryDisplayText(flatNode.node, isSelected);
let line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + labelTimestamp + content; if (isSelected) { line = theme.getSelectionBackgroundColor()(line); } lines.push(truncateToWidth(line, width)); }
lines.push( truncateToWidth( theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getStatusLabels()}`), width, ), );
return lines; }
private getEntryDisplayText(node: AgentConnectionSessionTreeNode, isSelected: boolean): string { const entry = node.entry; let result: string;
const normalize = (s: string) => s.replace(/[\n\t]/g, " ").trim();
switch (entry.type) { case "message": { if (entry.role === "user") { result = theme.fg("accent", "user: ") + normalize(entry.text); } else if (entry.role === "assistant") { const textContent = normalize(entry.text); if (textContent) { result = theme.fg("success", "assistant: ") + textContent; } else if (entry.stopReason === "aborted") { result = theme.fg("success", "assistant: ") + theme.fg("muted", "(aborted)"); } else if (entry.errorMessage) { const errMsg = normalize(entry.errorMessage).slice(0, 80); result = theme.fg("success", "assistant: ") + theme.fg("error", errMsg); } else { result = theme.fg("success", "assistant: ") + theme.fg("muted", "(no content)"); } } else if (entry.role === "toolResult") { const toolCall = entry.toolCallId ? this.toolCallDisplays.get(entry.toolCallId) : undefined; result = toolCall ? theme.fg("muted", toolCall) : theme.fg("muted", `[${entry.toolName ?? "tool"}]`); } else if (entry.role === "bashExecution") { result = theme.fg("dim", `[bash]: ${normalize(entry.text)}`); } else { result = theme.fg("dim", `[${entry.role}]`); } break; } case "custom_message": result = theme.fg("customMessageLabel", `[${entry.customType}]: `) + normalize(entry.text); break; case "compaction": { const tokens = Math.round(entry.tokensBefore / 1000); result = theme.fg("borderAccent", `[compaction: ${tokens}k tokens]`); break; } case "branch_summary": result = theme.fg("warning", `[branch summary]: `) + normalize(entry.text); break; case "model_change": result = theme.fg("dim", `[model: ${entry.modelId}]`); break; case "thinking_level_change": result = theme.fg("dim", `[thinking: ${entry.thinkingLevel}]`); break; case "service_tier_change": result = theme.fg("dim", `[service tier: ${entry.serviceTier}]`); break; case "custom": result = theme.fg("dim", `[custom: ${entry.customType}]`); break; case "child_usage_attributed": { const input = entry.childUsage.input + entry.childUsage.cacheRead + entry.childUsage.cacheWrite; const output = entry.childUsage.output; result = theme.fg("dim", `[child usage: ${input} input, ${output} output]`); break; } case "label": result = theme.fg("dim", `[label: ${entry.label ?? "(cleared)"}]`); break; case "session_info": result = entry.name ? [theme.fg("dim", "[title: "), theme.fg("dim", entry.name), theme.fg("dim", "]")].join("") : [theme.fg("dim", "[title: "), theme.italic(theme.fg("dim", "empty")), theme.fg("dim", "]")].join(""); break; default: result = ""; }
return isSelected ? theme.bold(result) : result; }
private formatLabelTimestamp(timestamp: string): string { const date = new Date(timestamp); const now = new Date(); const hours = date.getHours().toString().padStart(2, "0"); const minutes = date.getMinutes().toString().padStart(2, "0"); const time = `${hours}:${minutes}`;
if ( date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate() ) { return time; }
const month = date.getMonth() + 1; const day = date.getDate(); if (date.getFullYear() === now.getFullYear()) { return `${month}/${day} ${time}`; }
const year = date.getFullYear().toString().slice(-2); return `${year}/${month}/${day} ${time}`; }
handleInput(keyData: string): void { const kb = getKeybindings(); if (kb.matches(keyData, "tui.select.up")) { this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1; } else if (kb.matches(keyData, "tui.select.down")) { this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1; } else if (kb.matches(keyData, "app.tree.foldOrUp")) { const currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id; if (currentId && this.isFoldable(currentId) && !this.foldedNodes.has(currentId)) { this.foldedNodes.add(currentId); this.applyFilter(); } else { this.selectedIndex = this.findBranchSegmentStart("up"); } } else if (kb.matches(keyData, "app.tree.unfoldOrDown")) { const currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id; if (currentId && this.foldedNodes.has(currentId)) { this.foldedNodes.delete(currentId); this.applyFilter(); } else { this.selectedIndex = this.findBranchSegmentStart("down"); } } else if (kb.matches(keyData, "tui.editor.cursorLeft") || kb.matches(keyData, "tui.select.pageUp")) { // Page up this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines); } else if (kb.matches(keyData, "tui.editor.cursorRight") || kb.matches(keyData, "tui.select.pageDown")) { // Page down this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines); } else if (kb.matches(keyData, "tui.select.confirm")) { const selected = this.filteredNodes[this.selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.node.entry.id); } } else if (kb.matches(keyData, "tui.select.cancel")) { if (this.searchQuery) { this.searchQuery = ""; this.foldedNodes.clear(); this.applyFilter(); } else { this.onCancel?.(); } } else if (kb.matches(keyData, "app.tree.filter.default")) { // Direct filter: default this.filterMode = "default"; this.foldedNodes.clear(); this.applyFilter(); } else if (kb.matches(keyData, "app.tree.filter.noTools")) { // Toggle filter: no-tools ↔ default this.filterMode = this.filterMode === "no-tools" ? "default" : "no-tools"; this.foldedNodes.clear(); this.applyFilter(); } else if (kb.matches(keyData, "app.tree.filter.userOnly")) { // Toggle filter: user-only ↔ default this.filterMode = this.filterMode === "user-only" ? "default" : "user-only"; this.foldedNodes.clear(); this.applyFilter(); } else if (kb.matches(keyData, "app.tree.filter.labeledOnly")) { // Toggle filter: labeled-only ↔ default this.filterMode = this.filterMode === "labeled-only" ? "default" : "labeled-only"; this.foldedNodes.clear(); this.applyFilter(); } else if (kb.matches(keyData, "app.tree.filter.all")) { // Toggle filter: all ↔ default this.filterMode = this.filterMode === "all" ? "default" : "all"; this.foldedNodes.clear(); this.applyFilter(); } else if (kb.matches(keyData, "app.tree.filter.cycleBackward")) { // Cycle filter backwards const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"]; const currentIndex = modes.indexOf(this.filterMode); this.filterMode = modes[(currentIndex - 1 + modes.length) % modes.length]; this.foldedNodes.clear(); this.applyFilter(); } else if (kb.matches(keyData, "app.tree.filter.cycleForward")) { // Cycle filter forwards: default → no-tools → user-only → labeled-only → all → default const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"]; const currentIndex = modes.indexOf(this.filterMode); this.filterMode = modes[(currentIndex + 1) % modes.length]; this.foldedNodes.clear(); this.applyFilter(); } else if (kb.matches(keyData, "tui.editor.deleteCharBackward")) { if (this.searchQuery.length > 0) { this.searchQuery = this.searchQuery.slice(0, -1); this.foldedNodes.clear(); this.applyFilter(); } } else if (kb.matches(keyData, "app.tree.editLabel")) { const selected = this.filteredNodes[this.selectedIndex]; if (selected && this.onLabelEdit) { this.onLabelEdit(selected.node.entry.id, selected.node.label); } } else if (kb.matches(keyData, "app.tree.toggleLabelTimestamp")) { this.showLabelTimestamps = !this.showLabelTimestamps; } else { const hasControlChars = [...keyData].some((ch) => { const code = ch.charCodeAt(0); return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f); }); if (!hasControlChars && keyData.length > 0) { this.searchQuery += keyData; this.foldedNodes.clear(); this.applyFilter(); } } }
/** * Whether a node can be folded. A node is foldable if it has visible children * and is either a root (no visible parent) or a segment start (visible parent * has multiple visible children). */ private isFoldable(entryId: string): boolean { const children = this.visibleChildrenMap.get(entryId); if (!children || children.length === 0) return false; const parentId = this.visibleParentMap.get(entryId); if (parentId === null || parentId === undefined) return true; const siblings = this.visibleChildrenMap.get(parentId); return siblings !== undefined && siblings.length > 1; }
/** * Find the index of the next branch segment start in the given direction. * A segment start is the first child of a branch point. * * "up" walks the visible parent chain; "down" walks visible children * (always following the first child). */ private findBranchSegmentStart(direction: "up" | "down"): number { const selectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id; if (!selectedId) return this.selectedIndex;
const indexByEntryId = new Map(this.filteredNodes.map((node, i) => [node.node.entry.id, i])); let currentId: string = selectedId; if (direction === "down") { while (true) { const children: string[] = this.visibleChildrenMap.get(currentId) ?? []; if (children.length === 0) return indexByEntryId.get(currentId)!; if (children.length > 1) return indexByEntryId.get(children[0])!; currentId = children[0]; } }
// direction === "up" while (true) { const parentId: string | null = this.visibleParentMap.get(currentId) ?? null; if (parentId === null) return indexByEntryId.get(currentId)!; const children = this.visibleChildrenMap.get(parentId) ?? []; if (children.length > 1) { const segmentStart = indexByEntryId.get(currentId)!; if (segmentStart < this.selectedIndex) { return segmentStart; } } currentId = parentId; } }}
/** Component that displays the current search query */class SearchLine implements Component { constructor(private treeList: TreeList) {}
invalidate(): void {}
render(width: number): string[] { const query = this.treeList.getSearchQuery(); if (query) { return [truncateToWidth(` ${theme.fg("muted", "Type to search:")} ${theme.fg("accent", query)}`, width)]; } return [truncateToWidth(` ${theme.fg("muted", "Type to search:")}`, width)]; }
handleInput(_keyData: string): void {}}
/** Label input component shown when editing a label */class LabelInput implements Component, Focusable { private input: Input; private entryId: string; public onSubmit?: (entryId: string, label: string | undefined) => void; public onCancel?: () => void;
// Focusable implementation - propagate to input for IME cursor positioning private _focused = false; get focused(): boolean { return this._focused; } set focused(value: boolean) { this._focused = value; this.input.focused = value; }
constructor(entryId: string, currentLabel: string | undefined) { this.entryId = entryId; this.input = new Input(); if (currentLabel) { this.input.setValue(currentLabel); } }
invalidate(): void {}
render(width: number): string[] { const lines: string[] = []; const indent = " "; const availableWidth = width - indent.length; lines.push(truncateToWidth(`${indent}${theme.fg("muted", "Label (empty to remove):")}`, width)); lines.push(...this.input.render(availableWidth).map((line) => truncateToWidth(`${indent}${line}`, width))); lines.push( truncateToWidth( `${indent}${keyHint("tui.select.confirm", "save")} ${keyHint("tui.select.cancel", "cancel")}`, width, ), ); return lines; }
handleInput(keyData: string): void { const kb = getKeybindings(); if (kb.matches(keyData, "tui.select.confirm")) { const value = this.input.getValue().trim(); this.onSubmit?.(this.entryId, value || undefined); } else if (kb.matches(keyData, "tui.select.cancel")) { this.onCancel?.(); } else { this.input.handleInput(keyData); } }}
/** * Component that renders a session tree selector for navigation */export class TreeSelectorComponent extends Container implements Focusable { private treeList: TreeList; private labelInput: LabelInput | null = null; private labelInputContainer: Container; private treeContainer: Container; private onLabelChangeCallback?: (entryId: string, label: string | undefined) => void;
// Focusable implementation - propagate to labelInput when active for IME cursor positioning private _focused = false; get focused(): boolean { return this._focused; } set focused(value: boolean) { this._focused = value; // Propagate to labelInput when it's active if (this.labelInput) { this.labelInput.focused = value; } }
constructor( tree: AgentConnectionSessionTreeNode[], currentLeafId: string | null, terminalHeight: number, onSelect: (entryId: string) => void, onCancel: () => void, onLabelChange?: (entryId: string, label: string | undefined) => void, initialSelectedId?: string, initialFilterMode?: FilterMode, ) { super();
this.onLabelChangeCallback = onLabelChange; const maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2));
this.treeList = new TreeList(tree, currentLeafId, maxVisibleLines, initialSelectedId, initialFilterMode); this.treeList.onSelect = onSelect; this.treeList.onCancel = onCancel; this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel);
this.treeContainer = new Container(); this.treeContainer.addChild(this.treeList);
this.labelInputContainer = new Container();
this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); this.addChild(new Text(" Session Tree", 1, 0)); const filterKeys = [ keyText("app.tree.filter.default"), keyText("app.tree.filter.noTools"), keyText("app.tree.filter.userOnly"), keyText("app.tree.filter.labeledOnly"), keyText("app.tree.filter.all"), ].join("/"); const cycleKeys = `${keyText("app.tree.filter.cycleForward")}/${keyText("app.tree.filter.cycleBackward")}`; this.addChild( new TruncatedText( theme.fg( "muted", ` ↑/↓: move. ←/→: page. ^←/^→ or Alt+←/Alt+→: fold/branch. ${keyText("app.tree.editLabel")}: label. ${filterKeys}: filters (${cycleKeys} cycle). ${keyText("app.tree.toggleLabelTimestamp")}: label time`, ), 0, 0, ), ); this.addChild(new SearchLine(this.treeList)); this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); this.addChild(this.treeContainer); this.addChild(this.labelInputContainer); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); }
private showLabelInput(entryId: string, currentLabel: string | undefined): void { this.labelInput = new LabelInput(entryId, currentLabel); this.labelInput.onSubmit = (id, label) => { this.treeList.updateNodeLabel(id, label); this.onLabelChangeCallback?.(id, label); this.hideLabelInput(); }; this.labelInput.onCancel = () => this.hideLabelInput();
// Propagate current focused state to the new labelInput this.labelInput.focused = this._focused;
this.treeContainer.clear(); this.labelInputContainer.clear(); this.labelInputContainer.addChild(this.labelInput); }
private hideLabelInput(): void { this.labelInput = null; this.labelInputContainer.clear(); this.treeContainer.clear(); this.treeContainer.addChild(this.treeList); }
handleInput(keyData: string): void { if (this.labelInput) { this.labelInput.handleInput(keyData); } else { this.treeList.handleInput(keyData); } }
getTreeList(): TreeList { return this.treeList; }}