Skip to content

Commit 2dd5b08

Browse files
committed
feat(ui): visible 'Compacting…' banner during transformContext
Audit finding #10: compaction silently freezes the CLI for a few seconds on long sessions while glue.smart summarises older turns. Looks like a hang — the user has no signal the agent is working. New CompactionMonitor (pubsub, same shape as PermissionStore) bridges the transformContext hook to the TUI. The hook calls start() before the summarise call, end() in a finally so a thrown error doesn't leave the banner stuck. App.tsx subscribes and renders a yellow banner below MessageList with the active message count and an elapsed-seconds tick. Disappears the moment compaction returns.
1 parent db42cd7 commit 2dd5b08

3 files changed

Lines changed: 114 additions & 18 deletions

File tree

src/agent/agent.ts

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { isAbsolute, join, resolve } from "node:path";
33
import { Agent, type AgentEvent } from "@earendil-works/pi-agent-core";
44
import type { Model } from "@earendil-works/pi-ai";
55
import { CompactionEngine } from "../compaction/engine.js";
6+
import { CompactionMonitor } from "../compaction/monitor.js";
67
import { ConfigStore } from "../config/store.js";
78
import { DiagnosticsEngine, formatDiagnostics } from "../diagnostics/engine.js";
89
import { GlueClient, resolveGlueModels } from "../glue/client.js";
@@ -66,6 +67,7 @@ export interface AgentBundle {
6667
memory: MemoryStore;
6768
glue: GlueClient;
6869
compaction: CompactionEngine;
70+
compactionMonitor: CompactionMonitor;
6971
sessions: SessionStore;
7072
hooks: HookManager;
7173
diagnostics: DiagnosticsEngine;
@@ -103,6 +105,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
103105
apiKey: glueModels.apiKey,
104106
});
105107
const compaction = new CompactionEngine({ glue, modelId: model.id });
108+
const compactionMonitor = new CompactionMonitor();
106109
const sessions = new SessionStore({ cwd });
107110
const resumed = opts.resume ? sessions.load(model.id) : null;
108111

@@ -135,24 +138,31 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
135138
getApiKey: () => apiKey,
136139
transformContext: async (messages, signal) => {
137140
if (!compaction.needsCompaction(messages)) return messages;
138-
await hooks.dispatch(
139-
"PreCompact",
140-
{ event: "PreCompact", workingDir: cwd, messageCount: messages.length },
141-
signal,
142-
);
143-
const result = await compaction.compact(messages, signal);
144-
await hooks.dispatch(
145-
"PostCompact",
146-
{
147-
event: "PostCompact",
148-
workingDir: cwd,
149-
messageCount: result.messages.length,
150-
collapsedMessageCount: result.details.collapsedMessageCount,
151-
truncatedTokens: result.details.truncatedTokens,
152-
},
153-
signal,
154-
);
155-
return result.messages;
141+
compactionMonitor.start(messages.length);
142+
try {
143+
await hooks.dispatch(
144+
"PreCompact",
145+
{ event: "PreCompact", workingDir: cwd, messageCount: messages.length },
146+
signal,
147+
);
148+
const result = await compaction.compact(messages, signal);
149+
await hooks.dispatch(
150+
"PostCompact",
151+
{
152+
event: "PostCompact",
153+
workingDir: cwd,
154+
messageCount: result.messages.length,
155+
collapsedMessageCount: result.details.collapsedMessageCount,
156+
truncatedTokens: result.details.truncatedTokens,
157+
},
158+
signal,
159+
);
160+
return result.messages;
161+
} finally {
162+
// Always clear — even if compact() threw, the user shouldn't
163+
// see a stuck "Compacting…" banner forever.
164+
compactionMonitor.end();
165+
}
156166
},
157167
beforeToolCall: async (ctx, signal) => {
158168
// 1. Plan mode gate: block destructive tools entirely while planning.
@@ -279,6 +289,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
279289
memory,
280290
glue,
281291
compaction,
292+
compactionMonitor,
282293
sessions,
283294
hooks,
284295
diagnostics,

src/compaction/monitor.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Live status of in-flight compaction. The agent's transformContext hook
3+
* runs synchronously on the critical path: when it triggers, the user
4+
* sees a multi-second freeze with no visual signal — looks like a hang.
5+
*
6+
* CompactionMonitor is the bridge between the hook and the TUI: the hook
7+
* calls `start()` before the glue summarize call, `end()` after. The TUI
8+
* subscribes and renders a "Compacting…" banner for the duration. Same
9+
* pubsub pattern PermissionStore and UserQueryStore use.
10+
*/
11+
12+
export interface CompactionState {
13+
active: boolean;
14+
/** Wall-clock ms when start() was called; used for an elapsed-seconds label. */
15+
startedAt: number | null;
16+
/** How many messages were in the transcript when compaction kicked off. */
17+
messageCount: number;
18+
}
19+
20+
export type CompactionListener = (state: CompactionState) => void;
21+
22+
export class CompactionMonitor {
23+
private state: CompactionState = { active: false, startedAt: null, messageCount: 0 };
24+
private readonly listeners = new Set<CompactionListener>();
25+
26+
start(messageCount: number): void {
27+
this.state = { active: true, startedAt: Date.now(), messageCount };
28+
this.notify();
29+
}
30+
31+
end(): void {
32+
this.state = { active: false, startedAt: null, messageCount: 0 };
33+
this.notify();
34+
}
35+
36+
current(): CompactionState {
37+
return this.state;
38+
}
39+
40+
subscribe(listener: CompactionListener): () => void {
41+
this.listeners.add(listener);
42+
listener(this.current());
43+
return () => {
44+
this.listeners.delete(listener);
45+
};
46+
}
47+
48+
private notify(): void {
49+
const snapshot = this.current();
50+
for (const listener of this.listeners) listener(snapshot);
51+
}
52+
}

src/ui/App.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { generateSuggestion } from "../agent/prompt-suggestion.js";
99
import { routeUserInput } from "../agent/router.js";
1010
import { BUILTIN_COMMANDS } from "../commands/builtins.js";
1111
import { CommandRegistry } from "../commands/registry.js";
12+
import type { CompactionState } from "../compaction/monitor.js";
1213
import type { PermissionRequest } from "../permissions/store.js";
1314
import {
1415
buildAgentPrompt,
@@ -91,6 +92,7 @@ function ChatApp({ bundle, onExit }: ChatAppProps) {
9192
);
9293
const [permRequest, setPermRequest] = useState<PermissionRequest | undefined>(bundle.permissions.current());
9394
const [userQuery, setUserQuery] = useState<UserQuery | undefined>(bundle.userQueries.current());
95+
const [compactionState, setCompactionState] = useState(bundle.compactionMonitor.current());
9496
const [statusLines, setStatusLines] = useState<string[]>([]);
9597
// Cap the buffer so noisy emits (long /help, many !cmds) don't grow
9698
// the status pane indefinitely. 50 rows ≈ a screen on most terms.
@@ -202,6 +204,10 @@ function ChatApp({ bundle, onExit }: ChatAppProps) {
202204
return bundle.permissions.subscribe((req) => setPermRequest(req));
203205
}, [bundle]);
204206

207+
useEffect(() => {
208+
return bundle.compactionMonitor.subscribe((s) => setCompactionState(s));
209+
}, [bundle]);
210+
205211
useEffect(() => {
206212
return bundle.userQueries.subscribe((q) => setUserQuery(q));
207213
}, [bundle]);
@@ -436,6 +442,7 @@ function ChatApp({ bundle, onExit }: ChatAppProps) {
436442
</Box>
437443
)}
438444
<MessageList messages={state.messages} streaming={state.streaming} tools={state.tools} />
445+
{compactionState.active ? <CompactionBanner state={compactionState} /> : null}
439446
<ToolPanel tools={state.tools} />
440447
<TaskPanel tasks={tasks} />
441448
{statusLines.length > 0 ? (
@@ -551,3 +558,29 @@ function ExitOnCtrlC({ onExit }: { onExit: () => void }) {
551558
}, [onExit]);
552559
return null;
553560
}
561+
562+
/**
563+
* Visible banner while the agent is summarising older turns into a
564+
* compaction checkpoint. The work itself takes a few seconds on long
565+
* sessions — silent before, looked like a hang. The banner re-renders
566+
* its elapsed-seconds label every second so the user has a clear
567+
* "still working" signal.
568+
*/
569+
function CompactionBanner({ state }: { state: CompactionState }) {
570+
const [elapsed, setElapsed] = useState(0);
571+
useEffect(() => {
572+
if (!state.startedAt) return;
573+
const tick = () => setElapsed(Math.floor((Date.now() - (state.startedAt ?? Date.now())) / 1000));
574+
tick();
575+
const id = setInterval(tick, 1000);
576+
return () => clearInterval(id);
577+
}, [state.startedAt]);
578+
return (
579+
<Box paddingX={1} marginBottom={0}>
580+
<Text color="yellow">
581+
⟳ Compacting context ({state.messageCount} messages
582+
{elapsed > 0 ? ` · ${elapsed}s` : ""})…
583+
</Text>
584+
</Box>
585+
);
586+
}

0 commit comments

Comments
 (0)