From 1cc2263578e90bffe5ca189aca97b2f0b370075c Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Fri, 20 Feb 2026 20:27:58 -0800 Subject: [PATCH] TUI: bound chat-log growth to prevent render overflows --- CHANGELOG.md | 1 + src/tui/components/chat-log.test.ts | 44 ++++++++++++++++++++++++++ src/tui/components/chat-log.ts | 48 +++++++++++++++++++++++++---- 3 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 src/tui/components/chat-log.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1df7baf5baf..4b92acc08c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai - TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return `pairing required`, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux. - TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer. - TUI/Heartbeat: suppress heartbeat ACK/prompt noise in chat streaming when `showOk` is disabled, while still preserving non-ACK heartbeat alerts in final output. (#20228) Thanks @bhalliburton. +- TUI/History: cap chat-log component growth and prune stale render nodes/references so large default history loads no longer overflow render recursion with `RangeError: Maximum call stack size exceeded`. (#18068) Thanks @JaniJegoroff. - Memory/QMD: diversify mixed-source search ranking when both session and memory collections are present so session transcript hits no longer crowd out durable memory-file matches in top results. (#19913) Thanks @alextempr. - Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing `provider:default` mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii. - Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek. diff --git a/src/tui/components/chat-log.test.ts b/src/tui/components/chat-log.test.ts new file mode 100644 index 00000000000..02607568b1d --- /dev/null +++ b/src/tui/components/chat-log.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { ChatLog } from "./chat-log.js"; + +describe("ChatLog", () => { + it("caps component growth to avoid unbounded render trees", () => { + const chatLog = new ChatLog(20); + for (let i = 1; i <= 40; i++) { + chatLog.addSystem(`system-${i}`); + } + + expect(chatLog.children.length).toBe(20); + const rendered = chatLog.render(120).join("\n"); + expect(rendered).toContain("system-40"); + expect(rendered).not.toContain("system-1"); + }); + + it("drops stale streaming references when old components are pruned", () => { + const chatLog = new ChatLog(20); + chatLog.startAssistant("first", "run-1"); + for (let i = 0; i < 25; i++) { + chatLog.addSystem(`overflow-${i}`); + } + + // Should not throw if the original streaming component was pruned. + chatLog.updateAssistant("recreated", "run-1"); + + const rendered = chatLog.render(120).join("\n"); + expect(chatLog.children.length).toBe(20); + expect(rendered).toContain("recreated"); + }); + + it("drops stale tool references when old components are pruned", () => { + const chatLog = new ChatLog(20); + chatLog.startTool("tool-1", "read_file", { path: "a.txt" }); + for (let i = 0; i < 25; i++) { + chatLog.addSystem(`overflow-${i}`); + } + + // Should no-op safely after the tool component is pruned. + chatLog.updateToolResult("tool-1", { content: [{ type: "text", text: "done" }] }); + + expect(chatLog.children.length).toBe(20); + }); +}); diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts index 926b32c661a..4ddf1d5b1de 100644 --- a/src/tui/components/chat-log.ts +++ b/src/tui/components/chat-log.ts @@ -1,3 +1,4 @@ +import type { Component } from "@mariozechner/pi-tui"; import { Container, Spacer, Text } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { AssistantMessageComponent } from "./assistant-message.js"; @@ -5,10 +6,45 @@ import { ToolExecutionComponent } from "./tool-execution.js"; import { UserMessageComponent } from "./user-message.js"; export class ChatLog extends Container { + private readonly maxComponents: number; private toolById = new Map(); private streamingRuns = new Map(); private toolsExpanded = false; + constructor(maxComponents = 180) { + super(); + this.maxComponents = Math.max(20, Math.floor(maxComponents)); + } + + private dropComponentReferences(component: Component) { + for (const [toolId, tool] of this.toolById.entries()) { + if (tool === component) { + this.toolById.delete(toolId); + } + } + for (const [runId, message] of this.streamingRuns.entries()) { + if (message === component) { + this.streamingRuns.delete(runId); + } + } + } + + private pruneOverflow() { + while (this.children.length > this.maxComponents) { + const oldest = this.children[0]; + if (!oldest) { + return; + } + this.removeChild(oldest); + this.dropComponentReferences(oldest); + } + } + + private append(component: Component) { + this.addChild(component); + this.pruneOverflow(); + } + clearAll() { this.clear(); this.toolById.clear(); @@ -16,12 +52,12 @@ export class ChatLog extends Container { } addSystem(text: string) { - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.system(text), 1, 0)); + this.append(new Spacer(1)); + this.append(new Text(theme.system(text), 1, 0)); } addUser(text: string) { - this.addChild(new UserMessageComponent(text)); + this.append(new UserMessageComponent(text)); } private resolveRunId(runId?: string) { @@ -31,7 +67,7 @@ export class ChatLog extends Container { startAssistant(text: string, runId?: string) { const component = new AssistantMessageComponent(text); this.streamingRuns.set(this.resolveRunId(runId), component); - this.addChild(component); + this.append(component); return component; } @@ -53,7 +89,7 @@ export class ChatLog extends Container { this.streamingRuns.delete(effectiveRunId); return; } - this.addChild(new AssistantMessageComponent(text)); + this.append(new AssistantMessageComponent(text)); } dropAssistant(runId?: string) { @@ -75,7 +111,7 @@ export class ChatLog extends Container { const component = new ToolExecutionComponent(toolName, args); component.setExpanded(this.toolsExpanded); this.toolById.set(toolCallId, component); - this.addChild(component); + this.append(component); return component; }