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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user