Skip to content

Commit c8a35c4

Browse files
authored
fix: coalesce repeated idle TUI abort notices (#85167)
1 parent 577e64d commit c8a35c4

5 files changed

Lines changed: 108 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai
6262
- 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)
6363
- 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.
6464
- Trajectory/support: tolerate partial skill snapshot entries when building support metadata so rejected skill path scans no longer abort trajectory capture. (#71185) Thanks @lukeboyett.
65+
- TUI: coalesce repeated idle Esc abort notices into a single `no active run xN` system row instead of appending duplicate rows.
6566
- 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.
6667
- 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.
6768
- Slack: deliver native plugin approval prompts and updates when Slack native approvals are enabled, while keeping plugin approval authorization separate from exec approvers.

src/tui/components/chat-log.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,39 @@ describe("ChatLog", () => {
1515
expect(rendered).not.toContain("system-1");
1616
});
1717

18+
it("coalesces consecutive repeatable system messages", () => {
19+
const chatLog = new ChatLog(20);
20+
21+
chatLog.addSystem("no active run", { coalesceConsecutive: true });
22+
chatLog.addSystem("no active run", { coalesceConsecutive: true });
23+
chatLog.addSystem("no active run", { coalesceConsecutive: true });
24+
25+
const rendered = normalizeTestText(chatLog.render(120).join("\n"));
26+
expect(chatLog.children.length).toBe(1);
27+
expect(rendered).toContain("no active run x3");
28+
});
29+
30+
it("does not coalesce ordinary system messages", () => {
31+
const chatLog = new ChatLog(20);
32+
33+
chatLog.addSystem("status unchanged");
34+
chatLog.addSystem("status unchanged");
35+
36+
expect(chatLog.children.length).toBe(2);
37+
});
38+
39+
it("starts a new repeatable system message after other chat content", () => {
40+
const chatLog = new ChatLog(20);
41+
42+
chatLog.addSystem("no active run", { coalesceConsecutive: true });
43+
chatLog.addUser("hello");
44+
chatLog.addSystem("no active run", { coalesceConsecutive: true });
45+
46+
const rendered = normalizeTestText(chatLog.render(120).join("\n"));
47+
expect(chatLog.children.length).toBe(3);
48+
expect(rendered).not.toContain("no active run x2");
49+
});
50+
1851
it("drops stale streaming references when old components are pruned", () => {
1952
const chatLog = new ChatLog(20);
2053
chatLog.startAssistant("first", "run-1");

src/tui/components/chat-log.ts

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ import { UserMessageComponent } from "./user-message.js";
88

99
const PENDING_HISTORY_CLOCK_SKEW_TOLERANCE_MS = 60_000;
1010

11+
type RepeatableSystemMessage = {
12+
component: Container;
13+
textNode: Text;
14+
baseText: string;
15+
count: number;
16+
};
17+
1118
export class ChatLog extends Container {
1219
private readonly maxComponents: number;
1320
private toolById = new Map<string, ToolExecutionComponent>();
@@ -22,6 +29,7 @@ export class ChatLog extends Container {
2229
>();
2330
private btwMessage: BtwInlineMessage | null = null;
2431
private toolsExpanded = false;
32+
private repeatableSystemMessage: RepeatableSystemMessage | null = null;
2533

2634
constructor(maxComponents = 180) {
2735
super();
@@ -47,6 +55,9 @@ export class ChatLog extends Container {
4755
if (this.btwMessage === component) {
4856
this.btwMessage = null;
4957
}
58+
if (this.repeatableSystemMessage?.component === component) {
59+
this.repeatableSystemMessage = null;
60+
}
5061
}
5162

5263
private pruneOverflow() {
@@ -65,11 +76,17 @@ export class ChatLog extends Container {
6576
this.pruneOverflow();
6677
}
6778

79+
private appendNonSystem(component: Component) {
80+
this.repeatableSystemMessage = null;
81+
this.append(component);
82+
}
83+
6884
clearAll(opts?: { preservePendingUsers?: boolean }) {
6985
this.clear();
7086
this.toolById.clear();
7187
this.streamingRuns.clear();
7288
this.btwMessage = null;
89+
this.repeatableSystemMessage = null;
7390
if (!opts?.preservePendingUsers) {
7491
this.pendingUsers.clear();
7592
}
@@ -80,7 +97,7 @@ export class ChatLog extends Container {
8097
if (this.children.includes(entry.component)) {
8198
continue;
8299
}
83-
this.append(entry.component);
100+
this.appendNonSystem(entry.component);
84101
}
85102
}
86103

@@ -91,19 +108,42 @@ export class ChatLog extends Container {
91108
this.pendingUsers.clear();
92109
}
93110

94-
private createSystemMessage(text: string): Container {
111+
private formatRepeatedSystemText(text: string, count: number) {
112+
return count > 1 ? `${text} x${count}` : text;
113+
}
114+
115+
private createSystemMessage(text: string): RepeatableSystemMessage {
95116
const entry = new Container();
117+
const textNode = new Text(theme.system(text), 1, 0);
96118
entry.addChild(new Spacer(1));
97-
entry.addChild(new Text(theme.system(text), 1, 0));
98-
return entry;
119+
entry.addChild(textNode);
120+
return {
121+
component: entry,
122+
textNode,
123+
baseText: text,
124+
count: 1,
125+
};
99126
}
100127

101-
addSystem(text: string) {
102-
this.append(this.createSystemMessage(text));
128+
addSystem(text: string, opts?: { coalesceConsecutive?: boolean }) {
129+
if (
130+
opts?.coalesceConsecutive &&
131+
this.repeatableSystemMessage?.baseText === text &&
132+
this.children[this.children.length - 1] === this.repeatableSystemMessage.component
133+
) {
134+
this.repeatableSystemMessage.count += 1;
135+
this.repeatableSystemMessage.textNode.setText(
136+
theme.system(this.formatRepeatedSystemText(text, this.repeatableSystemMessage.count)),
137+
);
138+
return;
139+
}
140+
const message = this.createSystemMessage(text);
141+
this.append(message.component);
142+
this.repeatableSystemMessage = opts?.coalesceConsecutive ? message : null;
103143
}
104144

105145
addUser(text: string) {
106-
this.append(new UserMessageComponent(text));
146+
this.appendNonSystem(new UserMessageComponent(text));
107147
}
108148

109149
addPendingUser(runId: string, text: string, createdAt = Date.now()) {
@@ -116,7 +156,7 @@ export class ChatLog extends Container {
116156
}
117157
const component = new UserMessageComponent(text);
118158
this.pendingUsers.set(runId, { component, text, createdAt });
119-
this.append(component);
159+
this.appendNonSystem(component);
120160
return component;
121161
}
122162

@@ -192,7 +232,7 @@ export class ChatLog extends Container {
192232
}
193233
const component = new AssistantMessageComponent(text);
194234
this.streamingRuns.set(effectiveRunId, component);
195-
this.append(component);
235+
this.appendNonSystem(component);
196236
return component;
197237
}
198238

@@ -214,7 +254,7 @@ export class ChatLog extends Container {
214254
this.streamingRuns.delete(effectiveRunId);
215255
return;
216256
}
217-
this.append(new AssistantMessageComponent(text));
257+
this.appendNonSystem(new AssistantMessageComponent(text));
218258
}
219259

220260
dropAssistant(runId?: string) {
@@ -232,13 +272,13 @@ export class ChatLog extends Container {
232272
this.btwMessage.setResult(params);
233273
if (this.children[this.children.length - 1] !== this.btwMessage) {
234274
this.removeChild(this.btwMessage);
235-
this.append(this.btwMessage);
275+
this.appendNonSystem(this.btwMessage);
236276
}
237277
return this.btwMessage;
238278
}
239279
const component = new BtwInlineMessage(params);
240280
this.btwMessage = component;
241-
this.append(component);
281+
this.appendNonSystem(component);
242282
return component;
243283
}
244284

@@ -263,7 +303,7 @@ export class ChatLog extends Container {
263303
const component = new ToolExecutionComponent(toolName, args);
264304
component.setExpanded(this.toolsExpanded);
265305
this.toolById.set(toolCallId, component);
266-
this.append(component);
306+
this.appendNonSystem(component);
267307
return component;
268308
}
269309

src/tui/tui-session-actions.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,26 @@ describe("tui session actions", () => {
386386
expect(setActivityStatus).toHaveBeenCalledWith("aborted");
387387
});
388388

389+
it("coalesces repeated no-active-run abort notices", async () => {
390+
const addSystem = vi.fn();
391+
const requestRender = vi.fn();
392+
393+
const { abortActive } = createTestSessionActions({
394+
chatLog: {
395+
addSystem,
396+
clearAll: vi.fn(),
397+
} as unknown as import("./components/chat-log.js").ChatLog,
398+
tui: { requestRender } as unknown as import("@earendil-works/pi-tui").TUI,
399+
});
400+
401+
await abortActive();
402+
403+
expect(addSystem).toHaveBeenCalledWith("no active run", {
404+
coalesceConsecutive: true,
405+
});
406+
expect(requestRender).toHaveBeenCalledOnce();
407+
});
408+
389409
it("remembers the selected session after history loads", async () => {
390410
const listSessions = vi.fn().mockResolvedValue({
391411
ts: Date.now(),

src/tui/tui-session-actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ export function createSessionActions(context: SessionActionContext) {
397397
const abortActive = async () => {
398398
const runId = state.activeChatRunId ?? state.pendingChatRunId ?? null;
399399
if (!runId) {
400-
chatLog.addSystem("no active run");
400+
chatLog.addSystem("no active run", { coalesceConsecutive: true });
401401
tui.requestRender();
402402
return;
403403
}

0 commit comments

Comments
 (0)