From 64b81c3b8f14bd9c0807d931140d888e6b92ee33 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 19 Jun 2026 07:41:23 +0200 Subject: [PATCH] feat(coding-agent): standardized message card styling - Refreshed all injected message frames with a consistent rounded-outline card design. - Implemented icon-tagged headers for hooks, local skill invocations, and generic custom messages. - Updated skill invocation cards with home-shortened paths, dynamic line count units, and compact invocation argument display. - Unified branch summary styling with compaction banners to provide a consistent visual language for history collapse points. - Removed leaked absolute home directory paths from skill metadata displays. --- packages/coding-agent/CHANGELOG.md | 5 +- .../__tests__/skill-message.test.ts | 92 +++++++++++++++++++ .../components/branch-summary-message.ts | 46 ---------- .../components/chat-transcript-builder.ts | 7 +- .../components/compaction-summary-message.ts | 34 ++++++- .../src/modes/components/custom-message.ts | 5 +- .../src/modes/components/index.ts | 1 - .../src/modes/components/message-frame.ts | 16 ++-- .../src/modes/components/skill-message.ts | 56 +++++++---- .../src/modes/utils/ui-helpers.ts | 3 +- 10 files changed, 188 insertions(+), 77 deletions(-) create mode 100644 packages/coding-agent/src/modes/components/__tests__/skill-message.test.ts delete mode 100644 packages/coding-agent/src/modes/components/branch-summary-message.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5d0c04d04..b044bb559 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,10 +1,13 @@ # Changelog ## [Unreleased] - ### Changed +- Unified injected message styling with consistent rounded outlines and icon-tagged headers +- Refreshed branch summary messages with a uniform banner style to match compaction points +- Updated skill invocation UI with a compact header, home-shortened paths, and dynamic line counts - Refined session context to utilize history blocks instead of raw images for snapcompact summaries +- Refreshed the skill-prompt transcript message to match the rest of the TUI: an icon-tagged `✦ skill ` header (with invocation args trailing), a single meta line with a home-shortened, click-to-open path in the accent color and the prompt size in muted, and a rounded outline around the card. Replaces the old `[skill]` label with the flat `Skill:`/`Path:`/`Prompt:` key/value dump (which also leaked the absolute home directory). - Optimized network traffic by stripping tool descriptions from provider tool schemas - Snapcompact compaction summaries now reach the model as ordered history blocks instead of one lead-in text block plus appended images: plain text at the oldest edge, an imaged middle, then plain text at the newest edge. This matches the new text-first snapcompact archive layout and preserves chronological order in the provider prompt. diff --git a/packages/coding-agent/src/modes/components/__tests__/skill-message.test.ts b/packages/coding-agent/src/modes/components/__tests__/skill-message.test.ts new file mode 100644 index 000000000..84b8cfee5 --- /dev/null +++ b/packages/coding-agent/src/modes/components/__tests__/skill-message.test.ts @@ -0,0 +1,92 @@ +import * as os from "node:os"; +import * as path from "node:path"; +import { beforeAll, describe, expect, it } from "bun:test"; +import { Settings } from "../../../config/settings"; +import type { CustomMessage, SkillPromptDetails } from "../../../session/messages"; +import { getThemeByName, setThemeInstance, type Theme } from "../../theme/theme"; +import { SkillMessageComponent } from "../skill-message"; + +// Drop SGR colors and OSC 8 hyperlink wrappers so assertions see the visible text only. +const strip = (lines: readonly string[]): string => + lines + .join("\n") + .replace(/\x1b\]8;[^\x1b\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-9;]*m/g, ""); + +function makeMessage( + details: SkillPromptDetails, + content = "Use the atomic-commit workflow.", +): CustomMessage { + return { role: "custom", customType: "skill-prompt", content, display: true, details, timestamp: Date.now() }; +} + +describe("SkillMessageComponent", () => { + let uiTheme: Theme; + + beforeAll(async () => { + await Settings.init({ inMemory: true }); + const loaded = await getThemeByName("dark"); + if (!loaded) throw new Error("theme unavailable"); + uiTheme = loaded; + setThemeInstance(uiTheme); + }); + + const skillPath = path.join(os.homedir(), ".agent/skills/atomic-commit/SKILL.md"); + + it("renders a compact, outlined card instead of the archaic key:value dump", () => { + const component = new SkillMessageComponent( + makeMessage({ name: "atomic-commit", path: skillPath, lineCount: 88 }), + ); + const text = strip(component.render(80)); + + // New look: an icon-tagged "skill" header with the name and a single meta line. + expect(text).toContain("skill"); + expect(text).toContain("atomic-commit"); + expect(text).toContain("88 lines"); + + // The card is drawn with an outline. + expect(text).toContain(uiTheme.boxRound.topLeft); + expect(text).toContain(uiTheme.boxRound.bottomRight); + + // Path is home-shortened and never leaks the absolute home dir. + expect(text).toContain("~/.agent/skills/atomic-commit/SKILL.md"); + expect(text).not.toContain(os.homedir()); + + // The old archaic framing is gone. + expect(text).not.toContain("[skill]"); + expect(text).not.toContain("Skill:"); + expect(text).not.toContain("Path:"); + expect(text).not.toContain("Prompt:"); + }); + + it("flattens multi-line args onto the single-line header", () => { + const component = new SkillMessageComponent( + makeMessage({ name: "atomic-commit", path: skillPath, lineCount: 88, args: "stage all\nthen split" }), + ); + const text = strip(component.render(80)); + // Whitespace (including the newline) collapsed to single spaces so the header can't break. + expect(text).toContain("stage all then split"); + expect(text).not.toContain("stage all\nthen split"); + }); + + it("uses a singular unit for a one-line prompt", () => { + const component = new SkillMessageComponent(makeMessage({ name: "tiny", path: skillPath, lineCount: 1 })); + const text = strip(component.render(80)); + expect(text).toContain("1 line"); + expect(text).not.toContain("1 lines"); + }); + + it("reveals the prompt body under a calm subheader only when expanded", () => { + const details: SkillPromptDetails = { name: "atomic-commit", path: skillPath, lineCount: 88 }; + const body = "Step one: stage hunks."; + + const collapsed = new SkillMessageComponent(makeMessage(details, body)); + expect(strip(collapsed.render(80))).not.toContain(body); + + const expanded = new SkillMessageComponent(makeMessage(details, body)); + expanded.setExpanded(true); + const text = strip(expanded.render(80)); + expect(text).toContain("prompt"); + expect(text).toContain(body); + }); +}); diff --git a/packages/coding-agent/src/modes/components/branch-summary-message.ts b/packages/coding-agent/src/modes/components/branch-summary-message.ts deleted file mode 100644 index 80ee3515e..000000000 --- a/packages/coding-agent/src/modes/components/branch-summary-message.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Box, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; -import { getMarkdownTheme, theme } from "../../modes/theme/theme"; -import type { BranchSummaryMessage } from "../../session/messages"; - -/** - * Component that renders a branch summary message with collapsed/expanded state. - * Uses same background color as hook messages for visual consistency. - */ -export class BranchSummaryMessageComponent extends Box { - #expanded = false; - - constructor(private readonly message: BranchSummaryMessage) { - super(1, 1, t => theme.bg("customMessageBg", t)); - this.setIgnoreTight(true); - this.#updateDisplay(); - } - - setExpanded(expanded: boolean): void { - this.#expanded = expanded; - this.#updateDisplay(); - } - - override invalidate(): void { - super.invalidate(); - this.#updateDisplay(); - } - - #updateDisplay(): void { - this.clear(); - - const label = theme.fg("customMessageLabel", theme.bold("[branch]")); - this.addChild(new Text(label, 0, 0)); - this.addChild(new Spacer(1)); - - if (this.#expanded) { - const header = "**Branch Summary**\n\n"; - this.addChild( - new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), { - color: (text: string) => theme.fg("customMessageText", text), - }), - ); - } else { - this.addChild(new Text(theme.fg("customMessageText", "Branch summary (ctrl+o to expand)"), 0, 0)); - } - } -} diff --git a/packages/coding-agent/src/modes/components/chat-transcript-builder.ts b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts index 608cd2770..8ecbcc049 100644 --- a/packages/coding-agent/src/modes/components/chat-transcript-builder.ts +++ b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts @@ -36,9 +36,12 @@ import { createAdvisorMessageCard } from "./advisor-message"; import { AssistantMessageComponent } from "./assistant-message"; import { createBackgroundTanDispatchBlock } from "./background-tan-message"; import { BashExecutionComponent } from "./bash-execution"; -import { BranchSummaryMessageComponent } from "./branch-summary-message"; import { CollabPromptMessageComponent } from "./collab-prompt-message"; -import { CompactionSummaryMessageComponent, createHandoffSummaryMessageComponent } from "./compaction-summary-message"; +import { + BranchSummaryMessageComponent, + CompactionSummaryMessageComponent, + createHandoffSummaryMessageComponent, +} from "./compaction-summary-message"; import { CustomMessageComponent } from "./custom-message"; import { EvalExecutionComponent } from "./eval-execution"; import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message"; 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 9bbfed793..b19cc83de 100644 --- a/packages/coding-agent/src/modes/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/components/compaction-summary-message.ts @@ -1,6 +1,10 @@ import { Box, type Component, Markdown } from "@oh-my-pi/pi-tui"; import { getMarkdownTheme, theme } from "../../modes/theme/theme"; -import type { CompactionSummaryMessage, CustomMessage } from "../../session/messages"; +import { + type BranchSummaryMessage, + type CompactionSummaryMessage, + type CustomMessage, +} from "../../session/messages"; interface SummaryDividerOptions { label: () => string; @@ -156,6 +160,34 @@ export function createHandoffSummaryMessageComponent( return component; } +/** + * A branch summary collapses a side branch back into the main line. Render it + * with the same slim divider as `/compact` and handoff rather than a `[branch]` + * box, so every history-collapse point reads as one consistent banner. + */ +export class BranchSummaryMessageComponent implements Component { + #divider: SummaryDividerComponent; + + constructor(private readonly message: BranchSummaryMessage) { + this.#divider = new SummaryDividerComponent({ + label: () => `${theme.icon.branch} branch`, + detailMarkdown: () => `**Branch summary**\n\n${this.message.summary}`, + }); + } + + setExpanded(expanded: boolean): void { + this.#divider.setExpanded(expanded); + } + + invalidate(): void { + this.#divider.invalidate(); + } + + render(width: number): readonly string[] { + return this.#divider.render(width); + } +} + function getCustomMessageText(message: CustomMessage): string { if (typeof message.content === "string") return message.content; let firstText: string | undefined; diff --git a/packages/coding-agent/src/modes/components/custom-message.ts b/packages/coding-agent/src/modes/components/custom-message.ts index 67f699190..df2630fbe 100644 --- a/packages/coding-agent/src/modes/components/custom-message.ts +++ b/packages/coding-agent/src/modes/components/custom-message.ts @@ -46,12 +46,15 @@ export class CustomMessageComponent extends Container { } this.removeChild(this.#box); + // The transcript dispatch routes both `custom` and legacy `hookMessage` roles here: + // tag hooks with the hook glyph, other injected messages with a neutral package. + const isHook = (this.message.role as string) === "hookMessage"; const custom = renderFramedMessage({ message: this.message, box: this.#box, expanded: this.#expanded, customRenderer: this.customRenderer, - // Extension messages render full content; no collapse-on-fold behaviour. + icon: isHook ? theme.icon.extensionHook : theme.icon.package, }); if (custom) { diff --git a/packages/coding-agent/src/modes/components/index.ts b/packages/coding-agent/src/modes/components/index.ts index 67de87b26..fd4c8bd88 100644 --- a/packages/coding-agent/src/modes/components/index.ts +++ b/packages/coding-agent/src/modes/components/index.ts @@ -2,7 +2,6 @@ export * from "./assistant-message"; export * from "./bash-execution"; export * from "./bordered-loader"; -export * from "./branch-summary-message"; export * from "./compaction-summary-message"; export * from "./countdown-timer"; export * from "./custom-editor"; diff --git a/packages/coding-agent/src/modes/components/message-frame.ts b/packages/coding-agent/src/modes/components/message-frame.ts index 57157cfe4..d1893d792 100644 --- a/packages/coding-agent/src/modes/components/message-frame.ts +++ b/packages/coding-agent/src/modes/components/message-frame.ts @@ -34,16 +34,18 @@ export interface RebuildFrameOptions { message: M; box: Box; expanded: boolean; + /** Icon glyph shown before the customType in the default header (e.g. a hook/extension icon). */ + icon?: string; /** Collapse the markdown body to this many lines when `expanded` is false. Omit to never collapse. */ collapseAfterLines?: number; customRenderer?: FramedRenderer; } /** - * Attempt the custom renderer; on failure or undefined return, populate - * `box` with the default `[customType]` label + markdown body and return - * undefined. When the custom renderer succeeds, return its Component so the - * caller can mount it and skip the default box. + * Attempt the custom renderer; on failure or undefined return, populate `box` + * with the default outlined card — an `icon customType` header + markdown body — + * and return undefined. When the custom renderer succeeds, return its Component + * so the caller can mount it and skip the default box. */ export function renderFramedMessage(opts: RebuildFrameOptions): Component | undefined { if (opts.customRenderer) { @@ -56,9 +58,11 @@ export function renderFramedMessage(opts: RebuildFrameO } opts.box.clear(); + // Match the skill card: a subtle rounded outline so injected messages read as cards. + opts.box.setBorder({ chars: theme.boxRound, color: t => theme.fg("borderMuted", t) }); - const label = theme.fg("customMessageLabel", theme.bold(`[${opts.message.customType}]`)); - opts.box.addChild(new Text(label, 0, 0)); + const tag = opts.icon ? `${opts.icon} ${opts.message.customType}` : opts.message.customType; + opts.box.addChild(new Text(theme.fg("customMessageLabel", theme.bold(tag)), 0, 0)); opts.box.addChild(new Spacer(1)); let text: string; diff --git a/packages/coding-agent/src/modes/components/skill-message.ts b/packages/coding-agent/src/modes/components/skill-message.ts index 91cf36871..f054db578 100644 --- a/packages/coding-agent/src/modes/components/skill-message.ts +++ b/packages/coding-agent/src/modes/components/skill-message.ts @@ -3,6 +3,8 @@ import type { Component } from "@oh-my-pi/pi-tui"; import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; import { getMarkdownTheme, theme } from "../../modes/theme/theme"; import type { CustomMessage, SkillPromptDetails } from "../../session/messages"; +import { shortenPath } from "../../tools/render-utils"; +import { fileHyperlink } from "../../tui"; export class SkillMessageComponent extends Container { #box: Box; @@ -38,25 +40,26 @@ export class SkillMessageComponent extends Container { this.removeChild(this.#box); this.addChild(this.#box); this.#box.clear(); - - const label = theme.fg("customMessageLabel", theme.bold("[skill]")); - this.#box.addChild(new Text(label, 0, 0)); - this.#box.addChild(new Spacer(1)); + // Re-read symbols every rebuild so a runtime theme/preset switch refreshes the outline. + this.#box.setBorder({ chars: theme.boxRound, color: t => theme.fg("borderMuted", t) }); const details = this.message.details; - const args = details?.args?.trim(); - const infoLines = [ - `Skill: ${details?.name ?? "unknown"}`, - args ? `Args: ${args}` : undefined, - details?.path ? `Path: ${details.path}` : undefined, - typeof details?.lineCount === "number" ? `Prompt: ${details.lineCount} lines` : undefined, - ].filter((line): line is string => Boolean(line)); + const name = details?.name?.trim() || "unknown"; + // Collapse args to one line: a stray newline/tab in user-supplied args would split the header. + const args = details?.args?.replace(/\s+/g, " ").trim() ?? ""; - this.#box.addChild( - new Markdown(infoLines.join("\n"), 0, 0, getMarkdownTheme(), { - color: (value: string) => theme.fg("customMessageText", value), - }), - ); + // Header: icon-tag + skill name, with the invocation args trailing dimmed. + const tag = theme.fg("customMessageLabel", theme.bold(`${theme.icon.extensionSkill} skill`)); + let header = `${tag} ${theme.fg("customMessageText", theme.bold(name))}`; + if (args) { + header += ` ${theme.fg("dim", args)}`; + } + this.#box.addChild(new Text(header, 0, 0)); + + const meta = this.#metaLine(details); + if (meta) { + this.#box.addChild(new Text(meta, 0, 0)); + } if (!this.#expanded) { return; @@ -68,8 +71,7 @@ export class SkillMessageComponent extends Container { } this.#box.addChild(new Spacer(1)); - const promptHeader = theme.fg("customMessageLabel", theme.bold("Prompt")); - this.#box.addChild(new Text(promptHeader, 0, 0)); + this.#box.addChild(new Text(theme.fg("muted", "prompt"), 0, 0)); this.#box.addChild(new Spacer(1)); this.#contentComponent = new Markdown(text, 0, 0, getMarkdownTheme(), { @@ -78,6 +80,24 @@ export class SkillMessageComponent extends Container { this.#box.addChild(this.#contentComponent); } + /** Sub-line under the header: home-shortened (clickable) accent path · muted prompt size. */ + #metaLine(details: SkillPromptDetails | undefined): string | undefined { + const parts: string[] = []; + + const filePath = details?.path; + if (filePath) { + parts.push(fileHyperlink(filePath, theme.fg("accent", shortenPath(filePath)), { line: 1 })); + } + if (typeof details?.lineCount === "number") { + parts.push(theme.fg("muted", `${details.lineCount} ${details.lineCount === 1 ? "line" : "lines"}`)); + } + + if (parts.length === 0) { + return undefined; + } + return ` ${parts.join(theme.fg("muted", theme.sep.dot))}`; + } + #extractText(): string { if (typeof this.message.content === "string") { return this.message.content; diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 948643b8b..3b6086b2a 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -9,9 +9,10 @@ import { createAdvisorMessageCard } from "../../modes/components/advisor-message import { AssistantMessageComponent } from "../../modes/components/assistant-message"; import { createBackgroundTanDispatchBlock } from "../../modes/components/background-tan-message"; import { BashExecutionComponent } from "../../modes/components/bash-execution"; -import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message"; +import { detectCacheInvalidation } from "../../modes/components/cache-invalidation-marker"; import { CollabPromptMessageComponent } from "../../modes/components/collab-prompt-message"; import { + BranchSummaryMessageComponent, CompactionSummaryMessageComponent, createHandoffSummaryMessageComponent, } from "../../modes/components/compaction-summary-message";