diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b2dabe98e..c12c9d202f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Node/Linux: keep `OPENCLAW_GATEWAY_TOKEN` out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408) - Memory-core/dreaming: reuse stable narrative subagent session keys per workspace and phase while keeping per-run idempotency and bounded cleanup, so stale `dreaming-narrative-*` sessions do not accumulate. Fixes #68252, #69187, and #70402. (#70464) Thanks @chiyouYCH. - Trajectory/support: tolerate partial skill snapshot entries when building support metadata so rejected skill path scans no longer abort trajectory capture. (#71185) Thanks @lukeboyett. +- TUI: coalesce repeated idle Esc abort notices into a single `no active run xN` system row instead of appending duplicate rows. - Telegram: honor `channels.telegram.pollingStallThresholdMs` in the default isolated polling path, restarting silent workers instead of leaving inbound updates wedged. Fixes #83950. (#84861) Thanks @joshavant. - Slack: suppress reasoning payloads before reply delivery and dispatch accounting, so Slack monitor, slash-command, fallback, and direct reply paths do not leak model reasoning. Fixes #84319. (#84322) Thanks @ffluk3 and @joshavant. - Slack: deliver native plugin approval prompts and updates when Slack native approvals are enabled, while keeping plugin approval authorization separate from exec approvers. diff --git a/src/tui/components/chat-log.test.ts b/src/tui/components/chat-log.test.ts index bab5b1325a3..ecb89a48e3e 100644 --- a/src/tui/components/chat-log.test.ts +++ b/src/tui/components/chat-log.test.ts @@ -15,6 +15,39 @@ describe("ChatLog", () => { expect(rendered).not.toContain("system-1"); }); + it("coalesces consecutive repeatable system messages", () => { + const chatLog = new ChatLog(20); + + chatLog.addSystem("no active run", { coalesceConsecutive: true }); + chatLog.addSystem("no active run", { coalesceConsecutive: true }); + chatLog.addSystem("no active run", { coalesceConsecutive: true }); + + const rendered = normalizeTestText(chatLog.render(120).join("\n")); + expect(chatLog.children.length).toBe(1); + expect(rendered).toContain("no active run x3"); + }); + + it("does not coalesce ordinary system messages", () => { + const chatLog = new ChatLog(20); + + chatLog.addSystem("status unchanged"); + chatLog.addSystem("status unchanged"); + + expect(chatLog.children.length).toBe(2); + }); + + it("starts a new repeatable system message after other chat content", () => { + const chatLog = new ChatLog(20); + + chatLog.addSystem("no active run", { coalesceConsecutive: true }); + chatLog.addUser("hello"); + chatLog.addSystem("no active run", { coalesceConsecutive: true }); + + const rendered = normalizeTestText(chatLog.render(120).join("\n")); + expect(chatLog.children.length).toBe(3); + expect(rendered).not.toContain("no active run x2"); + }); + it("drops stale streaming references when old components are pruned", () => { const chatLog = new ChatLog(20); chatLog.startAssistant("first", "run-1"); diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts index 9df80af2e56..7fcfa0d94d7 100644 --- a/src/tui/components/chat-log.ts +++ b/src/tui/components/chat-log.ts @@ -8,6 +8,13 @@ import { UserMessageComponent } from "./user-message.js"; const PENDING_HISTORY_CLOCK_SKEW_TOLERANCE_MS = 60_000; +type RepeatableSystemMessage = { + component: Container; + textNode: Text; + baseText: string; + count: number; +}; + export class ChatLog extends Container { private readonly maxComponents: number; private toolById = new Map(); @@ -22,6 +29,7 @@ export class ChatLog extends Container { >(); private btwMessage: BtwInlineMessage | null = null; private toolsExpanded = false; + private repeatableSystemMessage: RepeatableSystemMessage | null = null; constructor(maxComponents = 180) { super(); @@ -47,6 +55,9 @@ export class ChatLog extends Container { if (this.btwMessage === component) { this.btwMessage = null; } + if (this.repeatableSystemMessage?.component === component) { + this.repeatableSystemMessage = null; + } } private pruneOverflow() { @@ -65,11 +76,17 @@ export class ChatLog extends Container { this.pruneOverflow(); } + private appendNonSystem(component: Component) { + this.repeatableSystemMessage = null; + this.append(component); + } + clearAll(opts?: { preservePendingUsers?: boolean }) { this.clear(); this.toolById.clear(); this.streamingRuns.clear(); this.btwMessage = null; + this.repeatableSystemMessage = null; if (!opts?.preservePendingUsers) { this.pendingUsers.clear(); } @@ -80,7 +97,7 @@ export class ChatLog extends Container { if (this.children.includes(entry.component)) { continue; } - this.append(entry.component); + this.appendNonSystem(entry.component); } } @@ -91,19 +108,42 @@ export class ChatLog extends Container { 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; + private formatRepeatedSystemText(text: string, count: number) { + return count > 1 ? `${text} x${count}` : text; } - addSystem(text: string) { - this.append(this.createSystemMessage(text)); + private createSystemMessage(text: string): RepeatableSystemMessage { + const entry = new Container(); + const textNode = new Text(theme.system(text), 1, 0); + entry.addChild(new Spacer(1)); + entry.addChild(textNode); + return { + component: entry, + textNode, + baseText: text, + count: 1, + }; + } + + addSystem(text: string, opts?: { coalesceConsecutive?: boolean }) { + if ( + opts?.coalesceConsecutive && + this.repeatableSystemMessage?.baseText === text && + this.children[this.children.length - 1] === this.repeatableSystemMessage.component + ) { + this.repeatableSystemMessage.count += 1; + this.repeatableSystemMessage.textNode.setText( + theme.system(this.formatRepeatedSystemText(text, this.repeatableSystemMessage.count)), + ); + return; + } + const message = this.createSystemMessage(text); + this.append(message.component); + this.repeatableSystemMessage = opts?.coalesceConsecutive ? message : null; } addUser(text: string) { - this.append(new UserMessageComponent(text)); + this.appendNonSystem(new UserMessageComponent(text)); } addPendingUser(runId: string, text: string, createdAt = Date.now()) { @@ -116,7 +156,7 @@ export class ChatLog extends Container { } const component = new UserMessageComponent(text); this.pendingUsers.set(runId, { component, text, createdAt }); - this.append(component); + this.appendNonSystem(component); return component; } @@ -192,7 +232,7 @@ export class ChatLog extends Container { } const component = new AssistantMessageComponent(text); this.streamingRuns.set(effectiveRunId, component); - this.append(component); + this.appendNonSystem(component); return component; } @@ -214,7 +254,7 @@ export class ChatLog extends Container { this.streamingRuns.delete(effectiveRunId); return; } - this.append(new AssistantMessageComponent(text)); + this.appendNonSystem(new AssistantMessageComponent(text)); } dropAssistant(runId?: string) { @@ -232,13 +272,13 @@ export class ChatLog extends Container { this.btwMessage.setResult(params); if (this.children[this.children.length - 1] !== this.btwMessage) { this.removeChild(this.btwMessage); - this.append(this.btwMessage); + this.appendNonSystem(this.btwMessage); } return this.btwMessage; } const component = new BtwInlineMessage(params); this.btwMessage = component; - this.append(component); + this.appendNonSystem(component); return component; } @@ -263,7 +303,7 @@ export class ChatLog extends Container { const component = new ToolExecutionComponent(toolName, args); component.setExpanded(this.toolsExpanded); this.toolById.set(toolCallId, component); - this.append(component); + this.appendNonSystem(component); return component; } diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index f2f78745dd5..04cbcce2185 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -386,6 +386,26 @@ describe("tui session actions", () => { expect(setActivityStatus).toHaveBeenCalledWith("aborted"); }); + it("coalesces repeated no-active-run abort notices", async () => { + const addSystem = vi.fn(); + const requestRender = vi.fn(); + + const { abortActive } = createTestSessionActions({ + chatLog: { + addSystem, + clearAll: vi.fn(), + } as unknown as import("./components/chat-log.js").ChatLog, + tui: { requestRender } as unknown as import("@earendil-works/pi-tui").TUI, + }); + + await abortActive(); + + expect(addSystem).toHaveBeenCalledWith("no active run", { + coalesceConsecutive: true, + }); + expect(requestRender).toHaveBeenCalledOnce(); + }); + it("remembers the selected session after history loads", async () => { const listSessions = vi.fn().mockResolvedValue({ ts: Date.now(), diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 8d65a146a84..91bb69309ac 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -397,7 +397,7 @@ export function createSessionActions(context: SessionActionContext) { const abortActive = async () => { const runId = state.activeChatRunId ?? state.pendingChatRunId ?? null; if (!runId) { - chatLog.addSystem("no active run"); + chatLog.addSystem("no active run", { coalesceConsecutive: true }); tui.requestRender(); return; }