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" ;
import { BtwInlineMessage } from "./btw-inline-message.js" ;
import { ToolExecutionComponent } from "./tool-execution.js" ;
import { UserMessageComponent } from "./user-message.js" ;
const PENDING_HISTORY_CLOCK_SKEW_TOLERANCE_MS = 60 _000 ;
export class ChatLog extends Container {
private readonly maxComponents: number;
private toolById = new Map<string, ToolExecutionComponent>();
private streamingRuns = new Map<string, AssistantMessageComponent>();
private pendingUsers = new Map<
string,
{
component: UserMessageComponent;
text: string;
createdAt: number;
}
>();
private btwMessage: BtwInlineMessage | null = null ;
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);
}
}
for (const [runId, entry] of this .pendingUsers.entries()) {
if (entry.component === component) {
this .pendingUsers.delete (runId);
}
}
if (this .btwMessage === component) {
this .btwMessage = null ;
}
}
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(opts?: { preservePendingUsers?: boolean }) {
this .clear();
this .toolById.clear();
this .streamingRuns.clear();
this .btwMessage = null ;
if (!opts?.preservePendingUsers) {
this .pendingUsers.clear();
}
}
restorePendingUsers() {
for (const entry of this .pendingUsers.values()) {
if (this .children.includes(entry.component)) {
continue ;
}
this .append(entry.component);
}
}
clearPendingUsers() {
for (const entry of this .pendingUsers.values()) {
this .removeChild(entry.component);
}
this .pendingUsers.clear();
}
private createSystemMessage(text: string): Container {
const entry = new Container();
entry.addChild(new Spacer(1 ));
entry.addChild(new Text(theme.system(text), 1 , 0 ));
return entry;
}
addSystem(text: string) {
this .append(this .createSystemMessage(text));
}
addUser(text: string) {
this .append(new UserMessageComponent(text));
}
addPendingUser(runId: string, text: string, createdAt = Date.now()) {
const existing = this .pendingUsers.get(runId);
if (existing) {
existing.text = text;
existing.createdAt = createdAt;
existing.component.setText(text);
return existing.component;
}
const component = new UserMessageComponent(text);
this .pendingUsers.set(runId, { component, text, createdAt });
this .append(component);
return component;
}
commitPendingUser(runId: string) {
return this .pendingUsers.delete (runId);
}
dropPendingUser(runId: string) {
const existing = this .pendingUsers.get(runId);
if (!existing) {
return false ;
}
this .removeChild(existing.component);
this .pendingUsers.delete (runId);
return true ;
}
hasPendingUser(runId: string) {
return this .pendingUsers.has(runId);
}
reconcilePendingUsers(
historyUsers: Array<{
text: string;
timestamp?: number | null ;
}>,
) {
const normalizedHistory = historyUsers
.map((entry) => ({
text: entry.text.trim(),
timestamp: typeof entry.timestamp === "number" ? entry.timestamp : null ,
}))
.filter((entry) => entry.text.length > 0 && entry.timestamp !== null );
const clearedRunIds: string[] = [];
for (const [runId, entry] of this .pendingUsers.entries()) {
const pendingText = entry.text.trim();
if (!pendingText) {
continue ;
}
const matchIndex = normalizedHistory.findIndex(
(historyEntry) =>
historyEntry.text === pendingText &&
(historyEntry.timestamp ?? 0 ) >=
entry.createdAt - PENDING_HISTORY_CLOCK_SKEW_TOLERANCE_MS,
);
if (matchIndex === -1 ) {
continue ;
}
if (this .children.includes(entry.component)) {
this .removeChild(entry.component);
}
this .pendingUsers.delete (runId);
clearedRunIds.push(runId);
normalizedHistory.splice(matchIndex, 1 );
}
return clearedRunIds;
}
countPendingUsers() {
return this .pendingUsers.size;
}
private resolveRunId(runId?: string) {
return runId ?? "default" ;
}
startAssistant(text: string, runId?: string) {
const effectiveRunId = this .resolveRunId(runId);
const existing = this .streamingRuns.get(effectiveRunId);
if (existing) {
existing.setText(text);
return existing;
}
const component = new AssistantMessageComponent(text);
this .streamingRuns.set(effectiveRunId, component);
this .append(component);
return component;
}
updateAssistant(text: string, runId?: string) {
const effectiveRunId = this .resolveRunId(runId);
const existing = this .streamingRuns.get(effectiveRunId);
if (!existing) {
this .startAssistant(text, runId);
return ;
}
existing.setText(text);
}
finalizeAssistant(text: string, runId?: string) {
const effectiveRunId = this .resolveRunId(runId);
const existing = this .streamingRuns.get(effectiveRunId);
if (existing) {
existing.setText(text);
this .streamingRuns.delete (effectiveRunId);
return ;
}
this .append(new AssistantMessageComponent(text));
}
dropAssistant(runId?: string) {
const effectiveRunId = this .resolveRunId(runId);
const existing = this .streamingRuns.get(effectiveRunId);
if (!existing) {
return ;
}
this .removeChild(existing);
this .streamingRuns.delete (effectiveRunId);
}
showBtw(params: { question: string; text: string; isError?: boolean }) {
if (this .btwMessage) {
this .btwMessage.setResult(params);
if (this .children[this .children.length - 1 ] !== this .btwMessage) {
this .removeChild(this .btwMessage);
this .append(this .btwMessage);
}
return this .btwMessage;
}
const component = new BtwInlineMessage(params);
this .btwMessage = component;
this .append(component);
return component;
}
dismissBtw() {
if (!this .btwMessage) {
return ;
}
this .removeChild(this .btwMessage);
this .btwMessage = null ;
}
hasVisibleBtw() {
return this .btwMessage !== null ;
}
startTool(toolCallId: string, toolName: string, args: unknown) {
const existing = this .toolById.get(toolCallId);
if (existing) {
existing.setArgs(args);
return existing;
}
const component = new ToolExecutionComponent(toolName, args);
component.setExpanded(this .toolsExpanded);
this .toolById.set(toolCallId, component);
this .append(component);
return component;
}
updateToolArgs(toolCallId: string, args: unknown) {
const existing = this .toolById.get(toolCallId);
if (!existing) {
return ;
}
existing.setArgs(args);
}
updateToolResult(
toolCallId: string,
result: unknown,
opts?: { isError?: boolean ; partial?: boolean },
) {
const existing = this .toolById.get(toolCallId);
if (!existing) {
return ;
}
if (opts?.partial) {
existing.setPartialResult(result as Record<string, unknown>);
return ;
}
existing.setResult(result as Record<string, unknown>, {
isError: opts?.isError,
});
}
setToolsExpanded(expanded: boolean ) {
this .toolsExpanded = expanded;
for (const tool of this .toolById.values()) {
tool.setExpanded(expanded);
}
}
}
Messung V0.5 in Prozent C=100 H=94 G=96
¤ Dauer der Verarbeitung: 0.2 Sekunden
(vorverarbeitet am 2026-05-26)
¤
*© Formatika GbR, Deutschland