From a1d1d4c44f97e5e191f5f228d10a3f2f3313373f Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 14 Jun 2026 04:29:11 +0200 Subject: [PATCH] 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 `` wrappers. - Refactored shared divider rendering into `SummaryDividerComponent` used by compaction and handoff messages. --- packages/coding-agent/CHANGELOG.md | 5 + .../src/modes/components/agent-hub.ts | 11 +- .../components/compaction-summary-message.ts | 151 +++++++++++++++--- .../src/modes/utils/ui-helpers.ts | 13 +- .../src/session/snapcompact-inline.ts | 5 +- .../compaction-summary-message.test.ts | 73 +++++++++ .../test/snapcompact-savings-journal.test.ts | 10 +- 7 files changed, 234 insertions(+), 34 deletions(-) create mode 100644 packages/coding-agent/test/modes/components/compaction-summary-message.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 11ed9a61a..f5b72755f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 `` 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)) diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index bef1d4c0d..d92bd6295 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -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, + this.#chatExpanded, + ); + if (handoffComponent) { + this.#trackExpandable(handoffComponent); + this.#chatLog.addChild(handoffComponent); + return; + } const component = new CustomMessageComponent( message as CustomMessage, this.#getMessageRenderer?.(message.customType), diff --git a/packages/coding-agent/src/modes/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/components/compaction-summary-message.ts index d2ebffb10..83e3fd1eb 100644 --- a/packages/coding-agent/src/modes/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/components/compaction-summary-message.ts @@ -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) { + 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, + 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): 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 = ""; + const closeTag = ""; + 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(); +} diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 91c5ffc19..21f3003e2 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -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, + 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, renderer); diff --git a/packages/coding-agent/src/session/snapcompact-inline.ts b/packages/coding-agent/src/session/snapcompact-inline.ts index e764a597e..004e0b1b1 100644 --- a/packages/coding-agent/src/session/snapcompact-inline.ts +++ b/packages/coding-agent/src/session/snapcompact-inline.ts @@ -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) { diff --git a/packages/coding-agent/test/modes/components/compaction-summary-message.test.ts b/packages/coding-agent/test/modes/components/compaction-summary-message.test.ts new file mode 100644 index 000000000..789232437 --- /dev/null +++ b/packages/coding-agent/test/modes/components/compaction-summary-message.test.ts @@ -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["content"]): CustomMessage { + 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( + `\n# Goal\nContinue the resize fix.\n\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: "\n# Goal\nContinue the resize fix.\n", + }, + ]), + 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(""); + expect(expanded).not.toContain(""); + }); + + 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(); + }); +}); diff --git a/packages/coding-agent/test/snapcompact-savings-journal.test.ts b/packages/coding-agent/test/snapcompact-savings-journal.test.ts index 61fb32ab6..eddf0d36d 100644 --- a/packages/coding-agent/test/snapcompact-savings-journal.test.ts +++ b/packages/coding-agent/test/snapcompact-savings-journal.test.ts @@ -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 },