Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pi-slate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ These work when `editor` is selected. No terminal configuration.
| Message length | `/slate message-length [default\|all\|<count>]` | How many chat messages stay on screen. `default` is 100. |
| Density | `/slate density [comfortable\|compact]` | Comfortable shows a › prompt in the composer; compact is tighter. |
| Composer metadata | `/slate footer [standard\|minimal]` | Legacy command alias: standard shows model and thinking on the composer; minimal hides them. Not footer-slot ownership. |
| Header logo | `/logo [original\|minimal]` | Toggle the stock brand mark and the white monochrome mark, or set a style explicitly. |
| Theme | `/theme` (or `/slate theme`) | Opens the Light/Dark picker: Latte and Frappé under Light, Macchiato, Mocha, Tokyo Night, and Black Metal under Dark. 15 palettes per Catppuccin flavor, live preview, no arguments. |
| Bugs | `/slate bug [file\|open]` | Copy a bug report, or open the npm package page. |

Expand All @@ -137,7 +138,7 @@ Changes are saved and require `/reload`; appearance settings stay live. A fresh

| Surface | Owns |
| --- | --- |
| `header` | Compact workspace identity and update hairline |
| `header` | Compact workspace identity |
| `footer` | Zero-row footer (hides Pi's native footer) |
| `editor` | Composer, keys, paste/images, working/thinking status |
| `sidebar` | Session dashboard, resources, tasks and images |
Expand Down
22 changes: 17 additions & 5 deletions pi-slate/extensions/pi-slate/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export function chromePaint(theme: Theme): (text: string) => string {
return (text) => theme.fg("border", text);
}

/** Separators inherit their line's metadata tone so they never stand out from nearby text. */
function metadataSeparator(theme: Theme, tone: "muted" | "dim" = "muted"): string {
return theme.fg(tone, " · ");
}

const ESCAPE_SEQUENCE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|_[^\x07]*(?:\x07|$)|\][^\x07]*(?:\x07|\x1b\\|$))/y;
const REVERSE_ON = "\x1b[7m";
const REVERSE_OFF = "\x1b[27m";
Expand Down Expand Up @@ -216,7 +221,7 @@ export function composerLabels(
if (visible.showTokens && input.tokens) parts.push(theme.fg("muted", input.tokens));
if (visible.showModel) parts.push(theme.fg("muted", input.model));
if (visible.showThinking && input.thinking) parts.push(theme.fg("dim", input.thinking));
const modelLabelText = parts.length ? ` ${parts.join(theme.fg("borderMuted", " · "))} ` : "";
const modelLabelText = parts.length ? ` ${parts.join(metadataSeparator(theme))} ` : "";
return { left: projectLabel, right: modelLabelText };
}

Expand Down Expand Up @@ -268,7 +273,7 @@ export type ComposerSource = {
thinking?: string;
footer: "standard" | "minimal";
theme: Theme;
context?: { tokens: string; resources: string; summary?: string };
context?: { tokens: string; resources: string };
working?: boolean;
showPid?: boolean;
effortBorderColor?: boolean;
Expand Down Expand Up @@ -457,8 +462,13 @@ export class ComposerEditor extends CustomEditor {
}
: undefined;
const pidLabel = src.showPid === true ? src.theme.fg("dim", `PID-${AGENT_PID}`) : "";
const resources = src.context
? src.context.resources.split(" · ")
.map((part) => src.theme.fg("dim", part))
.join(metadataSeparator(src.theme, "dim"))
: "";
return composerStatusContextEdge(
src.context ? src.theme.fg("dim", src.context.resources) : "",
resources,
width,
paint,
hiddenLineCount,
Expand All @@ -484,12 +494,14 @@ export class ComposerEditor extends CustomEditor {
width,
);
if (src.dashboardVisible === false && src.context) {
const rightParts = [src.context.summary ?? src.context.tokens];
const rightParts: string[] = [];
if (src.footer === "standard" && width >= 66) {
rightParts.push(modelStatusLabel(src.model, src.modelDisplay));
if (width >= 80 && src.thinking) rightParts.push(src.thinking);
}
const right = src.theme.fg("muted", ` ${rightParts.join(src.theme.fg("borderMuted", " · "))} `);
const right = rightParts.length
? ` ${rightParts.map((part) => src.theme.fg("muted", part)).join(metadataSeparator(src.theme))} `
: "";
return inscribedBorder(`${more}${left}`, right, width, text => this.borderColor(text), "└", "┘");
}
if (src.dashboardVisible) return inscribedBorder(`${more}${left}`, right, width, text => this.borderColor(text), "└", "┘");
Expand Down
83 changes: 0 additions & 83 deletions pi-slate/extensions/pi-slate/hairline.ts

This file was deleted.

100 changes: 62 additions & 38 deletions pi-slate/extensions/pi-slate/header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,28 @@ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import {
backgroundAnsi, foregroundAnsi, rgbColor, truncateToWidth, visibleWidth, type Component, type TerminalColorMode,
} from "@earendil-works/pi-tui";
import { hairlineTextWidth, wrapHairlineText, symmetricHairline } from "./hairline.ts";
import { paintLogo, PI_LOGO, type LogoStyle } from "./layout.ts";
import { paintLogo, type LogoStyle } from "./layout.ts";
import { sidebarText } from "./sidebar-data.ts";
import { formatUpdateNotice, type UpdateNotice } from "./updates.ts";

const CORAL = rgbColor(228, 138, 122);
const BLUE = rgbColor(79, 142, 179);
const YELLOW = rgbColor(234, 182, 93);
const RESET = "\x1b[0m";
const LOGO_CELLS = 4;

type PiMarkColor = "coral" | "blue" | "yellow";

// The stock Pi mark is a 4×4 source grid. Each terminal cell carries two
// source squares as a foreground/background half-block pair.
const PI_MARK_COLORS: readonly (readonly (PiMarkColor | null)[])[] = [
["coral", "coral", "coral", null],
["blue", null, "coral", null],
["blue", "blue", null, "yellow"],
["blue", null, null, "yellow"],
];
const PI_MARK_MASK = PI_MARK_COLORS.map((row) => row.map((color) => color !== null));
const MARK_COLORS: Record<PiMarkColor, typeof CORAL> = { coral: CORAL, blue: BLUE, yellow: YELLOW };

export function supportsPiLogo(env: NodeJS.ProcessEnv = process.env): boolean {
return env.TERM_PROGRAM !== "Apple_Terminal";
}
Expand All @@ -22,33 +33,58 @@ function colorMode(theme: Theme): TerminalColorMode {
return typeof theme.getColorMode === "function" ? theme.getColorMode() : "truecolor";
}

/** Stock Pi mark: 4 cells × 2 rows of half-blocks. Brand colors stay fixed. */
function renderPiMark(
cell: (top: boolean, bottom: boolean, row: number, column: number) => string,
): [string, string] {
const rows: string[] = [];
for (let row = 0; row < PI_MARK_MASK.length; row += 2) {
let line = "";
for (let column = 0; column < (PI_MARK_MASK[row]?.length ?? 0); column++) {
line += cell(
PI_MARK_MASK[row]?.[column] ?? false,
PI_MARK_MASK[row + 1]?.[column] ?? false,
row,
column,
);
}
rows.push(line);
}
return [rows[0] ?? "", rows[1] ?? ""];
}

/** Stock Pi mark: the brand-colored 4×2 half-block grid. */
export function piLogoLines(theme: Theme): [string, string] {
const mode = colorMode(theme);
const fg = (color: typeof CORAL) => foregroundAnsi(color, mode);
return [
`${fg(CORAL)}${backgroundAnsi(BLUE, mode)}▀${RESET}${fg(CORAL)}▀█${RESET} `,
`${fg(BLUE)}█▀${RESET} ${fg(YELLOW)}█${RESET}`,
];
return renderPiMark((_top, _bottom, row, column) => {
const top = PI_MARK_COLORS[row]?.[column] ?? null;
const bottom = PI_MARK_COLORS[row + 1]?.[column] ?? null;
if (!top && !bottom) return " ";
if (top && bottom) {
return top === bottom
? `${foregroundAnsi(MARK_COLORS[top], mode)}█${RESET}`
: `${foregroundAnsi(MARK_COLORS[top], mode)}${backgroundAnsi(MARK_COLORS[bottom], mode)}▀${RESET}`;
}
const color = top ?? bottom!;
return `${foregroundAnsi(MARK_COLORS[color], mode)}${top ? "▀" : "▄"}${RESET}`;
});
}

/** Classic Pi mark: 8 cells × 4 rows of full blocks, painted white in every theme. */
export function piMinimalLogoLines(theme: Theme): string[] {
/** Classic Pi mark: the same 4×2 half-block grid, painted white in every theme. */
export function piMinimalLogoLines(theme: Theme): [string, string] {
const truecolor = colorMode(theme) === "truecolor";
return PI_LOGO.map((line) => paintLogo(line, truecolor));
return renderPiMark((top, bottom) => {
if (top && bottom) return paintLogo("█", truecolor);
if (top) return paintLogo("▀", truecolor);
if (bottom) return paintLogo("▄", truecolor);
return " ";
});
}

export function piWordmark(theme: Theme): string {
const mode = colorMode(theme);
return `${foregroundAnsi(CORAL, mode)}P${RESET}${foregroundAnsi(YELLOW, mode)}i${RESET}`;
}

export function renderUpdateHairlines(
notice: string, width: number, paintDash: (text: string) => string, paintText: (text: string) => string,
): string[] {
return wrapHairlineText(notice, hairlineTextWidth(width)).map(line => symmetricHairline(paintText(line), width, paintDash));
}

function padLine(text: string, width: number): string {
const gap = Math.max(0, width - visibleWidth(text));
return `${text}${" ".repeat(gap)}`;
Expand All @@ -65,9 +101,9 @@ function indentedLine(text: string, width: number, cells: number): string {

export type HeaderIdentity = { version: string; model?: string; thinking?: string };

/** Official mark + product stack. No hairline without a notice. */
/** Official mark + product stack. */
export function renderSlateHeader(input: {
width: number; path: string; identity?: HeaderIdentity; notice?: string;
width: number; path: string; identity?: HeaderIdentity;
logo?: boolean; logoStyle?: LogoStyle; theme: Theme;
}): string[] {
const width = Math.max(0, input.width);
Expand All @@ -81,17 +117,10 @@ export function renderSlateHeader(input: {
const showLogo = input.logo ?? supportsPiLogo();
const modelLine = [model, thinking].filter(Boolean).join(" · ");
const rows: string[] = [];
if (showLogo && width >= 12 && input.logoStyle !== "original") {
const cells = 8;
const mark = piMinimalLogoLines(input.theme);
rows.push(
logoRow(mark[0]!, input.theme.fg("text", `Pi Agent v${version}`), width, cells),
logoRow(mark[1]!, modelLine ? input.theme.fg("muted", modelLine) : "", width, cells),
logoRow(mark[2]!, input.theme.fg("dim", path), width, cells),
logoRow(mark[3]!, "", width, cells),
);
} else if (showLogo && width >= 12) {
const [top, bottom] = piLogoLines(input.theme);
if (showLogo && width >= 12) {
const [top, bottom] = input.logoStyle === "original"
? piLogoLines(input.theme)
: piMinimalLogoLines(input.theme);
rows.push(
logoRow(top, input.theme.fg("text", `Pi Agent v${version}`), width, LOGO_CELLS),
logoRow(bottom, modelLine ? input.theme.fg("muted", modelLine) : "", width, LOGO_CELLS),
Expand All @@ -103,35 +132,30 @@ export function renderSlateHeader(input: {
if (modelLine) rows.push(padLine(truncateToWidth(input.theme.fg("muted", modelLine), width, "…"), width));
if (path) rows.push(padLine(truncateToWidth(input.theme.fg("dim", path), width, "…"), width));
}
if (input.notice) rows.push(...renderUpdateHairlines(input.notice, width,
text => input.theme.fg("border", text), text => input.theme.fg("accent", text)));
return rows;
}

export class SlateHeader implements Component {
private readonly theme: Theme;
private readonly getContext: () => ExtensionContext | undefined;
private readonly columnWidth: (width: number) => number;
private readonly getNotice: () => UpdateNotice;
private readonly getIdentity: () => HeaderIdentity;
private readonly getLogoStyle: () => LogoStyle;
constructor(theme: Theme, getContext: () => ExtensionContext | undefined,
columnWidth: (width: number) => number, getNotice: () => UpdateNotice,
columnWidth: (width: number) => number,
getIdentity: () => HeaderIdentity, getLogoStyle: () => LogoStyle = () => "minimal") {
this.theme = theme; this.getContext = getContext; this.columnWidth = columnWidth;
this.getNotice = getNotice; this.getIdentity = getIdentity;
this.getIdentity = getIdentity;
this.getLogoStyle = getLogoStyle;
}
invalidate(): void {}
render(width: number): string[] {
const ctx = this.getContext();
if (!ctx) return [];
const notice = formatUpdateNotice(this.getNotice());
const identity = this.getIdentity();
return renderSlateHeader({
width: this.columnWidth(width), path: ctx.cwd, identity,
theme: ctx.ui.theme ?? this.theme, logoStyle: this.getLogoStyle(),
...(notice ? { notice } : {}),
});
}
}
Loading
Loading