import type { TextContent } from "@earendil-works/pi-ai"; import type { SessionEntry, SessionTreeFlatNode } from "../../core/session-manager.js"; import type { AgentConnectionSessionTreeEntry, AgentConnectionSessionTreeFlatNode, AgentConnectionSessionTreeNode, AgentConnectionSessionTreeToolCall, } from "./types.js"; /** * The session tree renders one line per entry, so the wire carries the line, * not the entry. A message's text is clipped to the budget the tree has always * used for both its line and its search text (`extractContent`'s 200 * characters). Text the old client searched in full — custom messages, branch * summaries, bash commands — stays whole; those bodies are small next to the * images, tool results, and thinking blocks the tree never showed. */ export const TREE_MESSAGE_TEXT_LIMIT = 200; /** Text blocks of a message content payload, clipped to `limit` (unclipped when omitted). */ export function extractTreeText(content: unknown, limit?: number): string { if (typeof content === "string") return limit === undefined ? content : content.slice(0, limit); if (!Array.isArray(content)) return ""; let result = ""; for (const block of content) { if (typeof block !== "object" || block === null || !("type" in block)) continue; const typed = block as Partial; if (typed.type !== "text" || typeof typed.text !== "string") continue; result += typed.text; if (limit !== undefined && result.length >= limit) return result.slice(0, limit); } return result; } /** Whether any text block carries visible text (the assistant "no content" filter). */ export function treeContentHasText(content: unknown): boolean { if (typeof content === "string") return content.trim().length > 0; if (!Array.isArray(content)) return false; for (const block of content) { if (typeof block !== "object" || block === null || !("type" in block)) continue; const typed = block as Partial; if (typed.type === "text" && typeof typed.text === "string" && typed.text.trim().length > 0) return true; } return false; } const TOOL_ARGUMENT_PREVIEW_LIMIT = 40; const TOOL_COMMAND_PREVIEW_LIMIT = 50; function shortenHomePath(path: string, home: string | undefined): string { if (home && path.startsWith(home)) return `~${path.slice(home.length)}`; return path; } /** * One-line tool call description for the tree, next to the toolResult that * answers it. Kept pure so the daemon can compute it once per call instead of * shipping tool arguments with every tree entry. */ export function formatTreeToolCall( name: string, args: Record, home: string | undefined = process.env.HOME ?? process.env.USERPROFILE, ): string { switch (name) { case "edit": return `[edit: ${shortenHomePath(String(args.path || args.file_path || ""), home)}]`; case "bash": { const raw = String(args.command || ""); return `[bash: ${raw .replace(/[\n\t]/g, " ") .trim() .slice(0, TOOL_COMMAND_PREVIEW_LIMIT)}${raw.length > TOOL_COMMAND_PREVIEW_LIMIT ? "..." : ""}]`; } case "ipython": { const raw = String(args.code || ""); return `[ipython: ${raw .replace(/[\n\t]/g, " ") .trim() .slice(0, TOOL_COMMAND_PREVIEW_LIMIT)}${raw.length > TOOL_COMMAND_PREVIEW_LIMIT ? "..." : ""}]`; } default: { const serialized = JSON.stringify(args) ?? "{}"; return `[${name}: ${serialized.slice(0, TOOL_ARGUMENT_PREVIEW_LIMIT)}${serialized.length > TOOL_ARGUMENT_PREVIEW_LIMIT ? "..." : ""}]`; } } } function toolCallsOf(content: unknown): AgentConnectionSessionTreeToolCall[] | undefined { if (!Array.isArray(content)) return undefined; let calls: AgentConnectionSessionTreeToolCall[] | undefined; for (const block of content) { if (typeof block !== "object" || block === null || !("type" in block)) continue; const typed = block as { type?: string; id?: string; name?: string; arguments?: Record }; if (typed.type !== "toolCall" || typeof typed.id !== "string" || typeof typed.name !== "string") continue; calls ??= []; calls.push({ id: typed.id, display: formatTreeToolCall(typed.name, typed.arguments ?? {}) }); } return calls; } /** * Reduce one session entry to everything the tree view needs: structure, the * display line, and the fields the filters and search read. */ export function projectSessionTreeEntry(entry: SessionEntry): AgentConnectionSessionTreeEntry { const base = { id: entry.id, parentId: entry.parentId, timestamp: entry.timestamp }; switch (entry.type) { case "message": { const message = entry.message as { role?: string; content?: unknown; stopReason?: string; errorMessage?: string; toolCallId?: string; toolName?: string; command?: string; }; const role = message.role ?? "unknown"; if (role === "bashExecution") { return { ...base, type: "message", role, text: String(message.command ?? ""), hasText: false, }; } if (role === "toolResult") { // Not rendered — the tree shows the tool call, not its output — but the // search box has always matched against the result text too. return { ...base, type: "message", role, text: extractTreeText(message.content, TREE_MESSAGE_TEXT_LIMIT), hasText: false, ...(message.toolCallId === undefined ? {} : { toolCallId: message.toolCallId }), ...(message.toolName === undefined ? {} : { toolName: message.toolName }), }; } const toolCalls = role === "assistant" ? toolCallsOf(message.content) : undefined; return { ...base, type: "message", role, text: extractTreeText(message.content, TREE_MESSAGE_TEXT_LIMIT), hasText: treeContentHasText(message.content), ...(message.stopReason === undefined ? {} : { stopReason: message.stopReason }), ...(message.errorMessage === undefined ? {} : { errorMessage: message.errorMessage }), ...(toolCalls === undefined ? {} : { toolCalls }), }; } case "custom_message": return { ...base, type: "custom_message", customType: entry.customType, text: extractTreeText(entry.content), }; case "compaction": return { ...base, type: "compaction", tokensBefore: entry.tokensBefore }; case "branch_summary": return { ...base, type: "branch_summary", text: entry.summary }; case "model_change": return { ...base, type: "model_change", modelId: entry.modelId }; case "thinking_level_change": return { ...base, type: "thinking_level_change", thinkingLevel: entry.thinkingLevel }; case "service_tier_change": return { ...base, type: "service_tier_change", serviceTier: entry.serviceTier ?? "default" }; case "custom": return { ...base, type: "custom", customType: entry.customType }; case "child_usage_attributed": return { ...base, type: "child_usage_attributed", targetId: entry.targetId, childUsage: { input: entry.childUsage.input, output: entry.childUsage.output, cacheRead: entry.childUsage.cacheRead, cacheWrite: entry.childUsage.cacheWrite, }, }; case "label": return { ...base, type: "label", ...(entry.label === undefined ? {} : { label: entry.label }) }; case "session_info": return { ...base, type: "session_info", ...(entry.name === undefined ? {} : { name: entry.name }) }; case "session_state": return { ...base, type: "session_state" }; case "agent_status": return { ...base, type: "agent_status" }; case "git_state": return { ...base, type: "git_state" }; default: { const exhaustive: never = entry; throw new Error(`Unprojected session entry type: ${JSON.stringify(exhaustive)}`); } } } /** Project a whole session tree for transport. Structure and labels are preserved. */ export function projectSessionTreeFlatNodes( flatNodes: readonly SessionTreeFlatNode[], ): AgentConnectionSessionTreeFlatNode[] { return flatNodes.map((flatNode) => ({ entry: projectSessionTreeEntry(flatNode.entry), ...(flatNode.label === undefined ? {} : { label: flatNode.label }), ...(flatNode.labelTimestamp === undefined ? {} : { labelTimestamp: flatNode.labelTimestamp }), })); } /** Nest projected flat nodes by parent id, matching SessionManager.getTree() sibling order. */ export function buildSessionTreeFromFlatNodes( flatNodes: readonly AgentConnectionSessionTreeFlatNode[], ): AgentConnectionSessionTreeNode[] { const byId = new Map(); const roots: AgentConnectionSessionTreeNode[] = []; for (const flatNode of flatNodes) { byId.set(flatNode.entry.id, { ...flatNode, children: [] }); } for (const flatNode of flatNodes) { const entry = flatNode.entry; const node = byId.get(entry.id)!; const parent = entry.parentId === null || entry.parentId === entry.id ? undefined : byId.get(entry.parentId); if (parent) parent.children.push(node); else roots.push(node); } // Every node is already indexed, so sort each sibling array directly instead // of walking deep chains. Timestamps are parsed once per node, not per // comparison: a long session sorts thousands of siblings on every open. const sortKeys = new Map(); for (const node of byId.values()) { sortKeys.set(node, Date.parse(node.entry.timestamp)); } const compare = (left: AgentConnectionSessionTreeNode, right: AgentConnectionSessionTreeNode): number => (sortKeys.get(left) ?? 0) - (sortKeys.get(right) ?? 0); for (const node of byId.values()) { node.children.sort(compare); } return roots; }