Skip to content

Commit 3be75cf

Browse files
Merge pull request #549 from corbitsdev/cl-6934-surface-compaction-in-the-tui-when-it-actually-runs
Flash when compaction actually folds turns away
2 parents a4f7d86 + dcccee6 commit 3be75cf

7 files changed

Lines changed: 124 additions & 4 deletions

File tree

src/session/runtime-assembly.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,4 +285,38 @@ describe("createSessionPruningCompactor", () => {
285285
await llm.apply(turns as never, { state: {} as never, trigger: "test" });
286286
expect(captured).toBe(ctx);
287287
});
288+
289+
test("onFolded fires only when turns were actually folded", async () => {
290+
const folds: { turnsBefore: number; turnsAfter: number }[] = [];
291+
const summarize = async () => "summary";
292+
const folding = createSessionPruningCompactor({
293+
compactionMode: "llm",
294+
summarize,
295+
onFolded: (info) => folds.push(info),
296+
});
297+
const now = Date.now();
298+
const many = Array.from({ length: 8 }, (_, i) => ({
299+
role: i % 2 === 0 ? "user" : "assistant",
300+
content: [{ type: "text", text: `t${i}` }],
301+
timestamp: now,
302+
}));
303+
await folding.apply(many as never, { state: {} as never, trigger: "test" });
304+
expect(folds).toHaveLength(1);
305+
expect(folds[0]?.turnsBefore).toBe(8);
306+
expect(folds[0]?.turnsAfter).toBeLessThan(8);
307+
308+
const silent: { turnsBefore: number; turnsAfter: number }[] = [];
309+
const noop = createSessionPruningCompactor({
310+
compactionMode: "llm",
311+
summarize,
312+
onFolded: (info) => silent.push(info),
313+
});
314+
const few = Array.from({ length: 3 }, (_, i) => ({
315+
role: i % 2 === 0 ? "user" : "assistant",
316+
content: [{ type: "text", text: `t${i}` }],
317+
timestamp: now,
318+
}));
319+
await noop.apply(few as never, { state: {} as never, trigger: "test" });
320+
expect(silent).toEqual([]);
321+
});
288322
});

src/session/runtime-assembly.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,8 @@ export interface SessionPruningCompactorArgs {
268268
summarize: (turns: ConversationTurn[], ctx?: SummaryContext) => Promise<string>;
269269
summaryContext?: () => SummaryContext | undefined;
270270
telemetry?: Telemetry;
271+
/** Fires only when turns were actually folded away — not on no-ops. */
272+
onFolded?: (info: { turnsBefore: number; turnsAfter: number }) => void;
271273
}
272274

273275
/** Shared pruning-compactor defaults for the main session agent. */
@@ -296,6 +298,7 @@ export function createSessionPruningCompactor(args: SessionPruningCompactorArgs)
296298
turns_before: turnsBefore,
297299
turns_after: result.output.length,
298300
});
301+
args.onFolded?.({ turnsBefore, turnsAfter: result.output.length });
299302
}
300303
return result;
301304
},

src/tui/product-host.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
type ChromeLiveState,
2626
} from "./chrome-state.js";
2727
import {
28+
compactionFoldInfo,
29+
compactionNotice,
2830
grantApproval,
2931
grantNotice,
3032
hookNotice,
@@ -396,6 +398,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise<Produ
396398
config.eventEmitter.off("hook", onHook);
397399
config.eventEmitter.off("mcp.status", onMcpStatus);
398400
config.eventEmitter.off("permission.grant", onPermissionGrant);
401+
config.eventEmitter.off("compaction", onCompaction);
399402
bridge.dispose();
400403
// Cancels any flash still counting down: its expiry repaints, and after
401404
// teardown that repaint reaches a destroyed text buffer.
@@ -462,6 +465,12 @@ export async function mountProductHost(config: ProductHostConfig): Promise<Produ
462465
if (approval !== null) show(grantNotice(approval));
463466
}
464467

468+
function onCompaction(payload: unknown): void {
469+
if (disposed) return;
470+
const info = compactionFoldInfo(payload);
471+
if (info !== null) show(compactionNotice(info));
472+
}
473+
465474
// The renderer already owns the alternate screen and raw mode by this point,
466475
// but `dispose` has not been handed to any caller yet — a throw here would
467476
// leave the terminal wedged with nobody able to restore it.
@@ -616,6 +625,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise<Produ
616625
config.eventEmitter.on("hook", onHook);
617626
config.eventEmitter.on("mcp.status", onMcpStatus);
618627
config.eventEmitter.on("permission.grant", onPermissionGrant);
628+
config.eventEmitter.on("compaction", onCompaction);
619629

620630
return {
621631
shell,

src/tui/runner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1510,6 +1510,8 @@ export async function runTUI(initialConfig: Config): Promise<number> {
15101510
summarize: compactionSummarize,
15111511
summaryContext,
15121512
telemetry: liveTelemetry,
1513+
// Main-session folds only — exec runner and subagents stay silent.
1514+
onFolded: (info) => emitter.emit("compaction", info),
15131515
}),
15141516
},
15151517
});

src/tui/runtime-channels.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,31 @@ describe("permission.grant channel", () => {
193193
});
194194
});
195195

196+
describe("compaction channel", () => {
197+
test("a successful fold flashes before → after and holds no transcript row", async () => {
198+
const { host, emitter, frame, cleanup } = await mountHeadless();
199+
try {
200+
emitter.emit("compaction", { turnsBefore: 42, turnsAfter: 8 });
201+
const painted = await frame();
202+
expect(painted).toContain("context compacted · 42 → 8 turns");
203+
expect(host.shell.streamLog).toEqual([]);
204+
} finally {
205+
cleanup();
206+
}
207+
});
208+
209+
test("a bad payload paints nothing", async () => {
210+
const { host, emitter, frame, cleanup } = await mountHeadless();
211+
try {
212+
emitter.emit("compaction", { turnsBefore: 42 });
213+
expect(await frame()).not.toContain("context compacted");
214+
expect(host.shell.streamLog).toEqual([]);
215+
} finally {
216+
cleanup();
217+
}
218+
});
219+
});
220+
196221
describe("agents chrome (live strip above the prompt)", () => {
197222
test("setChrome with running agents paints the agents zone", async () => {
198223
const { host, frame, cleanup } = await mountHeadless({
@@ -266,7 +291,7 @@ describe("every emitted runtime channel has a subscriber", () => {
266291
emitted.delete("subagent.progress");
267292

268293
test("the runner still emits the channels this suite knows about", () => {
269-
for (const channel of ["hook", "mcp.status", "permission.grant"]) {
294+
for (const channel of ["hook", "mcp.status", "permission.grant", "compaction"]) {
270295
expect([...emitted]).toContain(channel);
271296
}
272297
});

src/tui/runtime-notices.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import { describe, expect, test } from "bun:test";
55

66
import {
7+
compactionFoldInfo,
8+
compactionNotice,
79
grantApproval,
810
grantNotice,
911
hookNotice,
@@ -104,6 +106,15 @@ describe("grantNotice", () => {
104106
});
105107
});
106108

109+
describe("compactionNotice", () => {
110+
test("flashes before → after turn counts", () => {
111+
expect(compactionNotice({ turnsBefore: 42, turnsAfter: 8 })).toEqual({
112+
kind: "flash",
113+
text: "context compacted · 42 → 8 turns",
114+
});
115+
});
116+
});
117+
107118
describe("payload validation", () => {
108119
test("hook events that are not hook.updated are dropped", () => {
109120
expect(lifecycleHookEvent({ type: "hooks.loaded", hooks: [] })).toBeNull();
@@ -140,4 +151,14 @@ describe("payload validation", () => {
140151
});
141152
expect(subAgentProgress({ description: "map callers" })).toBeNull();
142153
});
154+
155+
test("compaction payloads require both turn counts and reject junk", () => {
156+
expect(compactionFoldInfo({ turnsBefore: 42, turnsAfter: 8 })).toEqual({
157+
turnsBefore: 42,
158+
turnsAfter: 8,
159+
});
160+
expect(compactionFoldInfo({ turnsBefore: 42 })).toBeNull();
161+
expect(compactionFoldInfo(null)).toBeNull();
162+
expect(compactionFoldInfo("nope")).toBeNull();
163+
});
143164
});

src/tui/runtime-notices.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
2-
* Runtime side-channel notices: lifecycle hooks, MCP connection state and
3-
* recorded permission grants.
2+
* Runtime side-channel notices: lifecycle hooks, MCP connection state,
3+
* recorded permission grants, and successful context compaction.
44
*
55
* These channels are chatter by default and only sometimes news. The split
66
* this module encodes:
@@ -9,7 +9,8 @@
99
* still be able to read after scrolling away (a hook that failed, an MCP
1010
* server asking for authorization or refusing to connect);
1111
* - a **flash** is for confirmation of something they just caused, true only
12-
* for a moment (a hook that ran, a server that came up, a grant recorded);
12+
* for a moment (a hook that ran, a server that came up, a grant recorded,
13+
* a compaction that folded turns away);
1314
* - **null** is for inventory and intermediate states (`hooks.loaded`, a
1415
* server that is merely `connecting`) — the /hooks and /mcp panels own that.
1516
*
@@ -108,6 +109,19 @@ export function grantNotice(approval: Approval): RuntimeNotice {
108109
};
109110
}
110111

112+
export interface CompactionFoldInfo {
113+
readonly turnsBefore: number;
114+
readonly turnsAfter: number;
115+
}
116+
117+
/** Confirmation that context compaction actually folded turns away. */
118+
export function compactionNotice(info: CompactionFoldInfo): RuntimeNotice {
119+
return {
120+
kind: "flash",
121+
text: `context compacted · ${info.turnsBefore}${info.turnsAfter} turns`,
122+
};
123+
}
124+
111125
// ---------------------------------------------------------------------------
112126
// Emitter payload validation
113127
// ---------------------------------------------------------------------------
@@ -168,6 +182,17 @@ export function grantApproval(raw: unknown): Approval | null {
168182
return parsed.approval as Approval;
169183
}
170184

185+
const compactionPayload = type({
186+
turnsBefore: "number",
187+
turnsAfter: "number",
188+
});
189+
190+
export function compactionFoldInfo(raw: unknown): CompactionFoldInfo | null {
191+
const parsed = compactionPayload(raw);
192+
if (parsed instanceof type.errors) return null;
193+
return parsed;
194+
}
195+
171196
export interface SubAgentProgress {
172197
readonly description: string;
173198
readonly toolName: string;

0 commit comments

Comments
 (0)