From a2c9d38810ee900115ecdb79013e79d8553d30c2 Mon Sep 17 00:00:00 2001 From: Iury Souza Date: Tue, 4 Aug 2026 08:33:05 +0200 Subject: [PATCH] feat(startup-screen): add themed PI header --- package.json | 1 + packages/pi-ext/README.md | 7 ++ .../pi-ext/extensions/startup-screen/index.ts | 78 ++++++++++++++++ packages/pi-ext/package.json | 3 +- .../startup-screen/startup-screen.test.mjs | 93 +++++++++++++++++++ 5 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 packages/pi-ext/extensions/startup-screen/index.ts create mode 100644 packages/pi-ext/tests/startup-screen/startup-screen.test.mjs diff --git a/package.json b/package.json index f536ea3..bf9f984 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "pi": { "extensions": [ "./packages/pi-ext/extensions/custom-footer/custom-footer.ts", + "./packages/pi-ext/extensions/startup-screen/index.ts", "./packages/pi-ext/extensions/review/review.ts", "./packages/pi-ext/extensions/leader-key/index.ts", "./packages/pi-ext/extensions/chat-to-md/index.ts", diff --git a/packages/pi-ext/README.md b/packages/pi-ext/README.md index 62c2123..b6ef585 100644 --- a/packages/pi-ext/README.md +++ b/packages/pi-ext/README.md @@ -41,6 +41,7 @@ Omit a resource type to load all of it. Use an empty array to load none. | [pi-sem](extensions/pi-sem) | Entity-aware Git diff, context, history, blame, and impact tools powered by `sem`. | | [Pi Telescope](extensions/pi-telescope) | Native fuzzy finder for sessions, files, commands, and other providers. | | [Custom Footer](extensions/custom-footer) | Core session line plus one bounded prioritized extension-status line. | +| [Startup Screen](extensions/startup-screen) | Centred PI wordmark coloured by the active theme, with a responsive compact fallback. | | [Tool Presentation](extensions/tool-presentation) | Tidy compact built-in cards with lazy highlighted edit/write details. | | [Permissions](extensions/permissions) | Switchable `yolo`, `safe`, and `read-only` command policies. | | [Session Query](extensions/session-query) | Ask focused questions about previous Pi session files. | @@ -86,6 +87,12 @@ Use `/mode` to switch policy: Rules merge from project `.agents/permissions.json`, global `~/.pi/agent/permissions.json`, and built-ins. +### Startup screen + +The startup screen replaces only Pi's built-in header. It centres a PI wordmark, +uses active theme roles for its colour sweep, shows the current path, and falls +back to a compact π mark when the terminal is narrower than the full logo. + ### Compact tools and footer Tool Presentation is the sole owner of Pi's `read`, `bash`, `edit`, `write`, diff --git a/packages/pi-ext/extensions/startup-screen/index.ts b/packages/pi-ext/extensions/startup-screen/index.ts new file mode 100644 index 0000000..06e1121 --- /dev/null +++ b/packages/pi-ext/extensions/startup-screen/index.ts @@ -0,0 +1,78 @@ +import { homedir } from "node:os"; +import { relative, sep } from "node:path"; +import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent"; +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; + +const WORDMARK = [ + "██████ ██", + "██ ██ ██", + "██████ ██", + "██ ██", + "██ ██", + "██ ██", +] as const; + +const WORDMARK_WIDTH = Math.max(...WORDMARK.map((line) => visibleWidth(line))); +const WORDMARK_ROLES = ["mdLink", "mdHeading", "accent", "syntaxVariable"] as const; + +function centerLine(text: string, width: number): string { + if (width <= 0) return ""; + const fitted = truncateToWidth(text, width, ""); + const padding = Math.max(0, Math.floor((width - visibleWidth(fitted)) / 2)); + return `${" ".repeat(padding)}${fitted}`; +} + +function paintWordmarkLine(line: string, theme: Theme): string { + let painted = ""; + let segment = ""; + let previousRole: (typeof WORDMARK_ROLES)[number] | undefined; + + for (const [column, character] of [...line].entries()) { + const roleIndex = Math.min( + WORDMARK_ROLES.length - 1, + Math.floor((column * WORDMARK_ROLES.length) / WORDMARK_WIDTH), + ); + const role = WORDMARK_ROLES[roleIndex]!; + if (previousRole !== undefined && role !== previousRole) { + painted += theme.fg(previousRole, segment); + segment = ""; + } + segment += character; + previousRole = role; + } + + return previousRole === undefined ? painted : painted + theme.fg(previousRole, segment); +} + +export function formatStartupPath(cwd: string, home = homedir()): string { + const display = cwd === home + ? "~" + : cwd.startsWith(`${home}${sep}`) + ? `~/${relative(home, cwd)}` + : cwd; + return display.replace(/[\u0000-\u001f\u007f-\u009f]/g, ""); +} + +export function renderStartupHeader(width: number, theme: Theme, cwd: string): string[] { + const path = centerLine(theme.fg("muted", formatStartupPath(cwd)), width); + if (width < WORDMARK_WIDTH) { + return ["", centerLine(theme.bold(theme.fg("accent", "π")), width), "", path, ""]; + } + + const logo = WORDMARK.map((line) => centerLine(paintWordmarkLine(line, theme), width)); + return ["", ...logo, "", path, ""]; +} + +export default function startupScreen(pi: ExtensionAPI) { + pi.on("session_start", (_event, ctx) => { + if (ctx.mode !== "tui") return; + ctx.ui.setHeader((_tui, theme) => ({ + render: (width: number) => renderStartupHeader(width, theme, ctx.cwd), + invalidate() {}, + })); + }); + + pi.on("session_shutdown", (_event, ctx) => { + if (ctx.mode === "tui") ctx.ui.setHeader(undefined); + }); +} diff --git a/packages/pi-ext/package.json b/packages/pi-ext/package.json index 53bec09..a933f47 100644 --- a/packages/pi-ext/package.json +++ b/packages/pi-ext/package.json @@ -61,7 +61,7 @@ "homepage": "https://github.com/iurysza/pi-extensions/tree/main/packages/pi-ext#readme", "scripts": { "sem:evaluate": "node extensions/pi-sem/bin/sem-eval.mjs", - "test": "npm run test:pi-sem && npm run test:tool-presentation && node --test tests/leader-key/favourite-models.test.mjs tests/leader-key/skill-editor.test.mjs tests/chat-to-md/chat-to-md.test.mjs tests/handoff/dependency-contract.test.mjs", + "test": "npm run test:pi-sem && npm run test:tool-presentation && node --test tests/leader-key/favourite-models.test.mjs tests/leader-key/skill-editor.test.mjs tests/chat-to-md/chat-to-md.test.mjs tests/handoff/dependency-contract.test.mjs tests/startup-screen/startup-screen.test.mjs", "test:pi-sem": "node --test tests/pi-sem/core.test.mjs tests/review/sem-guidance.test.mjs", "test:tool-presentation": "node scripts/test-tool-presentation.mjs", "typecheck": "tsc --noEmit -p tsconfig.json", @@ -72,6 +72,7 @@ "pi": { "extensions": [ "./extensions/custom-footer/custom-footer.ts", + "./extensions/startup-screen/index.ts", "./extensions/review/review.ts", "./extensions/leader-key/index.ts", "./extensions/chat-to-md/index.ts", diff --git a/packages/pi-ext/tests/startup-screen/startup-screen.test.mjs b/packages/pi-ext/tests/startup-screen/startup-screen.test.mjs new file mode 100644 index 0000000..be583c2 --- /dev/null +++ b/packages/pi-ext/tests/startup-screen/startup-screen.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import startupScreen, { + formatStartupPath, + renderStartupHeader, +} from "../../extensions/startup-screen/index.ts"; + +function fakeTheme(roles = []) { + return { + fg(role, text) { + roles.push(role); + return text; + }, + bold(text) { + return text; + }, + }; +} + +describe("startup screen rendering", () => { + it("formats home-relative paths and removes terminal control characters", () => { + assert.equal(formatStartupPath("/home/iury", "/home/iury"), "~"); + assert.equal(formatStartupPath("/home/iury/dev/project", "/home/iury"), "~/dev/project"); + assert.equal( + formatStartupPath("/home/iury/dev/\u001b]0;owned\u0007project", "/home/iury"), + "~/dev/]0;ownedproject", + ); + }); + + it("renders the full wordmark with active theme roles", () => { + const roles = []; + const lines = renderStartupHeader(40, fakeTheme(roles), "/workspace/project"); + + assert.equal(lines.length, 10); + assert.equal(lines.at(-3), ""); + assert.ok(lines.some((line) => line.includes("██████"))); + assert.ok(lines.some((line) => line.includes("/workspace/project"))); + assert.ok(roles.includes("mdLink")); + assert.ok(roles.includes("mdHeading")); + assert.ok(roles.includes("accent")); + assert.ok(roles.includes("syntaxVariable")); + assert.ok(lines.every((line) => visibleWidth(line) <= 40)); + }); + + it("falls back to a compact mark on narrow terminals", () => { + const lines = renderStartupHeader(8, fakeTheme(), "/very/long/project/path"); + + assert.equal(lines.length, 5); + assert.equal(lines.at(-3), ""); + assert.ok(lines.includes(" π")); + assert.ok(lines.every((line) => visibleWidth(line) <= 8)); + }); +}); + +describe("startup screen extension", () => { + it("owns only the TUI header for the session lifecycle", () => { + const handlers = new Map(); + const pi = { on: (event, handler) => handlers.set(event, handler) }; + const headerChanges = []; + const ctx = { + cwd: "/repo", + mode: "tui", + ui: { setHeader: (value) => headerChanges.push(value) }, + }; + + startupScreen(pi); + handlers.get("session_start")({}, ctx); + assert.equal(typeof headerChanges[0], "function"); + + const component = headerChanges[0]({}, fakeTheme()); + assert.ok(component.render(80).some((line) => line.includes("/repo"))); + + handlers.get("session_shutdown")({}, ctx); + assert.equal(headerChanges.at(-1), undefined); + }); + + it("does nothing outside TUI mode", () => { + const handlers = new Map(); + const pi = { on: (event, handler) => handlers.set(event, handler) }; + let changed = false; + const ctx = { + cwd: "/repo", + mode: "print", + ui: { setHeader: () => { changed = true; } }, + }; + + startupScreen(pi); + handlers.get("session_start")({}, ctx); + handlers.get("session_shutdown")({}, ctx); + assert.equal(changed, false); + }); +}); -- 2.51.2