TUI: bound chat-log growth to prevent render overflows

This commit is contained in:
Vignesh Natarajan
2026-02-20 20:27:58 -08:00
parent 2227840989
commit 1cc2263578
3 changed files with 87 additions and 6 deletions

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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<string, ToolExecutionComponent>();
private streamingRuns = new Map<string, AssistantMessageComponent>();
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;
}