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.
This commit is contained in:
can1357
2026-06-19 07:41:23 +02:00
parent afef9fc68a
commit 64b81c3b8f
10 changed files with 188 additions and 77 deletions
+4 -1
View File
@@ -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 <name>` 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.
@@ -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<SkillPromptDetails> {
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);
});
});
@@ -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));
}
}
}
@@ -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";
@@ -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<unknown>): string {
if (typeof message.content === "string") return message.content;
let firstText: string | undefined;
@@ -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) {
@@ -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";
@@ -34,16 +34,18 @@ export interface RebuildFrameOptions<M extends FramedMessage> {
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<M>;
}
/**
* 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<M extends FramedMessage>(opts: RebuildFrameOptions<M>): Component | undefined {
if (opts.customRenderer) {
@@ -56,9 +58,11 @@ export function renderFramedMessage<M extends FramedMessage>(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;
@@ -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;
@@ -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";