feat(coding-agent/modes): added shared compact divider for handoff summary

- Routed `customType: "handoff"` messages to the compact divider path in Agent Hub and UI helpers.
- Added handoff summary expansion that extracts context text and strips `<handoff-context>` wrappers.
- Refactored shared divider rendering into `SummaryDividerComponent` used by compaction and handoff messages.
This commit is contained in:
can1357
2026-06-14 04:29:11 +02:00
parent d63b1530f8
commit a1d1d4c44f
7 changed files with 234 additions and 34 deletions
+5
View File
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
### Added
- Added `snapcompact-savings.jsonl` journaling for snapcompact tool-result compaction, recording session, provider, model, tool call, and estimated token savings whenever tool output is rendered as image frames
@@ -10,6 +11,10 @@
- Added the `mnemopi.embeddingVariant` setting (`en` | `multilingual`) selecting a stronger SOTA local embedding model — `en` → `BAAI/bge-base-en-v1.5` (768d), `multilingual` → `intfloat/multilingual-e5-large` (1024d). Resolution precedence is `mnemopi.embeddingModel` setting > `MNEMOPI_EMBEDDING_MODEL` env > variant default, so the documented env override is still honored. Changing the active model wipes and rebuilds stored embeddings on the next writable start ([#2476](https://github.com/can1357/oh-my-pi/issues/2476))
- Added a `/guided-goal` slash command that interviews you to refine an objective before enabling goal mode, then seeds goal mode with the agreed objective. The bounded interview (up to six turns) runs on the plan or slow model and falls back with a hint when the goal is still too vague ([#2502](https://github.com/can1357/oh-my-pi/issues/2502)).
### Changed
- Changed `handoff` custom messages (`customType: "handoff"`) to render in the transcript as a compaction-style expandable divider in both the main session and Agent Hub views, and expanded handoff details now show the handoff context body without `<handoff-context>` tags
### Fixed
- Fixed the tool-result renderer re-shaping on every `invalidate()` (spinner tick, stream chunk, resize, keystroke), which made large grep/find/read results block the main thread for seconds and made typing sluggish. `ToolExecutionComponent.#updateDisplay()` now memoizes on a dirty key (result version, expand state, partial flag, spinner frame, image visibility, theme epoch, background-task freeze state, the resolved terminal image protocol, and a display-input version that covers streamed call args, the async edit-diff preview, and Kitty image conversions) and a `#displayBuilt` guard that also fast-paths the `#contentText` fallback, so the O(result-size) shaping runs once per change instead of every frame without freezing streamed args, previews, converted images, a backgrounded task settling to its static form, or images that arrive before the async image-protocol probe resolves. Image-bearing results also re-shape on terminal resize (keyed on the resolved image dimensions only when images are present) so inline images rescale, while image-free results never re-shape on resize ([#2484](https://github.com/can1357/oh-my-pi/issues/2484))
@@ -48,7 +48,7 @@ import { AssistantMessageComponent } from "./assistant-message";
import { BashExecutionComponent } from "./bash-execution";
import { BranchSummaryMessageComponent } from "./branch-summary-message";
import { CollabPromptMessageComponent } from "./collab-prompt-message";
import { CompactionSummaryMessageComponent } from "./compaction-summary-message";
import { CompactionSummaryMessageComponent, createHandoffSummaryMessageComponent } from "./compaction-summary-message";
import { CustomMessageComponent } from "./custom-message";
import { DynamicBorder } from "./dynamic-border";
import { EvalExecutionComponent } from "./eval-execution";
@@ -1206,6 +1206,15 @@ export class AgentHubOverlayComponent extends Container {
this.#chatLog.addChild(card);
return;
}
const handoffComponent = createHandoffSummaryMessageComponent(
message as CustomMessage<unknown>,
this.#chatExpanded,
);
if (handoffComponent) {
this.#trackExpandable(handoffComponent);
this.#chatLog.addChild(handoffComponent);
return;
}
const component = new CustomMessageComponent(
message as CustomMessage<unknown>,
this.#getMessageRenderer?.(message.customType),
@@ -1,22 +1,18 @@
import { Box, type Component, Markdown } from "@oh-my-pi/pi-tui";
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
import type { CompactionSummaryMessage } from "../../session/messages";
import type { CompactionSummaryMessage, CustomMessage } from "../../session/messages";
/**
* Compaction point in the transcript, rendered as a slim horizontal divider:
*
* ──────── 📷 compacted · ctrl+o ────────
*
* The conversation above the divider stays visible (display transcript keeps
* full history); only the LLM context was reset. Expanding (ctrl+o) reveals
* the compaction summary below the divider.
*/
export class CompactionSummaryMessageComponent implements Component {
interface SummaryDividerOptions {
label: () => string;
detailMarkdown: () => string;
}
class SummaryDividerComponent implements Component {
#expanded = false;
#cache?: { width: number; lines: string[] };
#detail?: Box;
constructor(private readonly message: CompactionSummaryMessage) {}
constructor(private readonly options: SummaryDividerOptions) {}
setExpanded(expanded: boolean): void {
if (this.#expanded === expanded) return;
@@ -44,7 +40,7 @@ export class CompactionSummaryMessageComponent implements Component {
#divider(width: number): string {
const rule = theme.tree.horizontal;
const label = `${theme.icon.camera} compacted`;
const label = this.options.label();
// sep.dot ships pre-padded (" · "); trim so the hint joins with single spaces.
const hint = `${theme.sep.dot.trim()} ctrl+o`;
const plainWidth = Bun.stringWidth(`${label} ${hint}`, { countAnsiEscapeCodes: false });
@@ -66,22 +62,125 @@ export class CompactionSummaryMessageComponent implements Component {
#detailBox(): Box {
if (this.#detail) return this.#detail;
const box = new Box(1, 1, t => theme.bg("customMessageBg", t));
const tokenStr = this.message.tokensBefore.toLocaleString();
const frameCount = this.message.images?.length ?? 0;
const frameNote =
frameCount > 0 ? `\n\n_${frameCount} snapcompact frame${frameCount === 1 ? "" : "s"} attached_` : "";
box.addChild(
new Markdown(
`**Compacted from ${tokenStr} tokens**\n\n${this.message.summary}${frameNote}`,
0,
0,
getMarkdownTheme(),
{
color: (text: string) => theme.fg("customMessageText", text),
},
),
new Markdown(this.options.detailMarkdown(), 0, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("customMessageText", text),
}),
);
this.#detail = box;
return box;
}
}
/**
* Compaction point in the transcript, rendered as a slim horizontal divider:
*
* ──────── 📷 compacted · ctrl+o ────────
*
* The conversation above the divider stays visible (display transcript keeps
* full history); only the LLM context was reset. Expanding (ctrl+o) reveals
* the compaction summary below the divider.
*/
export class CompactionSummaryMessageComponent implements Component {
#divider: SummaryDividerComponent;
constructor(private readonly message: CompactionSummaryMessage) {
this.#divider = new SummaryDividerComponent({
label: () => `${theme.icon.camera} compacted`,
detailMarkdown: () => this.#detailMarkdown(),
});
}
setExpanded(expanded: boolean): void {
this.#divider.setExpanded(expanded);
}
invalidate(): void {
this.#divider.invalidate();
}
render(width: number): readonly string[] {
return this.#divider.render(width);
}
#detailMarkdown(): string {
const tokenStr = this.message.tokensBefore.toLocaleString();
const frameCount = this.message.images?.length ?? 0;
const frameNote =
frameCount > 0 ? `\n\n_${frameCount} snapcompact frame${frameCount === 1 ? "" : "s"} attached_` : "";
return `**Compacted from ${tokenStr} tokens**\n\n${this.message.summary}${frameNote}`;
}
}
/**
* Handoff is a compaction strategy too, but it is persisted as a custom message
* so the LLM sees the handoff-specific developer context. Render it with the
* same divider affordance as `/compact` instead of the generic `[handoff]` box.
*/
export class HandoffSummaryMessageComponent implements Component {
#divider: SummaryDividerComponent;
constructor(private readonly message: CustomMessage<unknown>) {
this.#divider = new SummaryDividerComponent({
label: () => `${theme.icon.context} handoff`,
detailMarkdown: () => this.#detailMarkdown(),
});
}
setExpanded(expanded: boolean): void {
this.#divider.setExpanded(expanded);
}
invalidate(): void {
this.#divider.invalidate();
}
render(width: number): readonly string[] {
return this.#divider.render(width);
}
#detailMarkdown(): string {
const document = extractHandoffDocument(getCustomMessageText(this.message));
return `**Handoff context**\n\n${document || "_No handoff content._"}`;
}
}
export function createHandoffSummaryMessageComponent(
message: CustomMessage<unknown>,
expanded: boolean,
): HandoffSummaryMessageComponent | undefined {
if (message.customType !== "handoff" || !message.display) return undefined;
const component = new HandoffSummaryMessageComponent(message);
component.setExpanded(expanded);
return component;
}
function getCustomMessageText(message: CustomMessage<unknown>): string {
if (typeof message.content === "string") return message.content;
let firstText: string | undefined;
let parts: string[] | undefined;
for (const content of message.content) {
if (content.type !== "text") continue;
if (firstText === undefined) {
firstText = content.text;
continue;
}
if (parts === undefined) {
parts = [firstText];
}
parts.push(content.text);
}
return parts === undefined ? (firstText ?? "") : parts.join("\n");
}
function extractHandoffDocument(text: string): string {
const openTag = "<handoff-context>";
const closeTag = "</handoff-context>";
const openIndex = text.indexOf(openTag);
if (openIndex === -1) return text.trim();
const contentStart = openIndex + openTag.length;
const closeIndex = text.indexOf(closeTag, contentStart);
const document = closeIndex === -1 ? text.slice(contentStart) : text.slice(contentStart, closeIndex);
return document.trim();
}
@@ -8,7 +8,10 @@ import { AssistantMessageComponent } from "../../modes/components/assistant-mess
import { BashExecutionComponent } from "../../modes/components/bash-execution";
import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message";
import { CollabPromptMessageComponent } from "../../modes/components/collab-prompt-message";
import { CompactionSummaryMessageComponent } from "../../modes/components/compaction-summary-message";
import {
CompactionSummaryMessageComponent,
createHandoffSummaryMessageComponent,
} from "../../modes/components/compaction-summary-message";
import { CustomMessageComponent } from "../../modes/components/custom-message";
import { DynamicBorder } from "../../modes/components/dynamic-border";
import { EvalExecutionComponent } from "../../modes/components/eval-execution";
@@ -235,6 +238,14 @@ export class UiHelpers {
this.ctx.chatContainer.addChild(card);
return [card];
}
const handoffComponent = createHandoffSummaryMessageComponent(
message as CustomMessage<unknown>,
this.ctx.toolOutputExpanded,
);
if (handoffComponent) {
this.ctx.chatContainer.addChild(handoffComponent);
break;
}
const renderer = this.ctx.viewSession.extensionRunner?.getMessageRenderer(message.customType);
// Both HookMessage and CustomMessage have the same structure, cast for compatibility
const component = new CustomMessageComponent(message as CustomMessage<unknown>, renderer);
@@ -485,7 +485,10 @@ export class SnapcompactInlineTransformer {
const frames = this.#framesFor(this.#toolCache, swap.id, target.text, shape);
messages[target.index] = { ...target.message, content: [{ type: "text", text: toolResultNote }, ...frames] };
changed = true;
savings.push({ toolCallId: swap.id, savedTokens: Math.max(0, swap.textTokens - swap.frames * shape.frameTokenEstimate) });
savings.push({
toolCallId: swap.id,
savedTokens: Math.max(0, swap.textTokens - swap.frames * shape.frameTokenEstimate),
});
}
if (savings.length > 0) this.onToolResultSavings?.(savings, model);
if (this.options.renderToolResults) {
@@ -0,0 +1,73 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import {
createHandoffSummaryMessageComponent,
HandoffSummaryMessageComponent,
} from "@oh-my-pi/pi-coding-agent/modes/components/compaction-summary-message";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { CustomMessage } from "@oh-my-pi/pi-coding-agent/session/messages";
beforeAll(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true });
await initTheme(false);
});
afterAll(() => {
resetSettingsForTest();
});
function makeHandoffMessage(content: CustomMessage<unknown>["content"]): CustomMessage<unknown> {
return {
role: "custom",
customType: "handoff",
content,
display: true,
attribution: "agent",
timestamp: Date.now(),
};
}
describe("handoff summary divider", () => {
it("renders handoff custom messages with the compact divider instead of a framed block", () => {
const component = createHandoffSummaryMessageComponent(
makeHandoffMessage(
`<handoff-context>\n# Goal\nContinue the resize fix.\n</handoff-context>\n\nThe above is a handoff document.`,
),
false,
);
expect(component).toBeInstanceOf(HandoffSummaryMessageComponent);
const collapsed = Bun.stripANSI(component!.render(80).join("\n"));
expect(collapsed).toContain("handoff");
expect(collapsed).toContain("ctrl+o");
expect(collapsed).not.toContain("[handoff]");
expect(collapsed).not.toContain("Continue the resize fix");
});
it("expands to the handoff document without the provider-only XML wrapper", () => {
const component = createHandoffSummaryMessageComponent(
makeHandoffMessage([
{
type: "text",
text: "<handoff-context>\n# Goal\nContinue the resize fix.\n</handoff-context>",
},
]),
true,
);
expect(component).toBeInstanceOf(HandoffSummaryMessageComponent);
const expanded = Bun.stripANSI(component!.render(80).join("\n"));
expect(expanded).toContain("Handoff context");
expect(expanded).toContain("Continue the resize fix");
expect(expanded).not.toContain("<handoff-context>");
expect(expanded).not.toContain("</handoff-context>");
});
it("leaves unrelated custom messages on the generic renderer path", () => {
const message = makeHandoffMessage("Not a handoff.");
message.customType = "extension-note";
expect(createHandoffSummaryMessageComponent(message, false)).toBeUndefined();
});
});
@@ -72,11 +72,11 @@ describe("snapcompact savings journal", () => {
it("writes nothing without a session or for non-positive savings", async () => {
const journal = await tmpJournal();
await createSnapcompactSavingsRecorder(() => null, journal)([{ toolCallId: "call_1", savedTokens: 5000 }], model());
await createSnapcompactSavingsRecorder(
() => "/proj/session.jsonl",
journal,
)(
await createSnapcompactSavingsRecorder(() => null, journal)(
[{ toolCallId: "call_1", savedTokens: 5000 }],
model(),
);
await createSnapcompactSavingsRecorder(() => "/proj/session.jsonl", journal)(
[
{ toolCallId: "call_zero", savedTokens: 0 },
{ toolCallId: "call_neg", savedTokens: -10 },