diff --git a/src/components/BrandBanner.tsx b/src/components/BrandBanner.tsx
new file mode 100644
index 000000000..6f204fce9
--- /dev/null
+++ b/src/components/BrandBanner.tsx
@@ -0,0 +1,35 @@
+import { Box } from "ink";
+import { PACKAGE_VERSION } from "../constants";
+import { Badge } from "./ui/badge";
+import { Divider } from "./ui/divider";
+import { GradientText } from "./ui/gradient-text";
+
+const LOGO: string[] = [
+ "█▀█ █▀▀ █▀▀ █▀█ ▀█▀ █▀▀ █▀█ █▀▄ █▀▀ █▀▀ █ ▀█▀",
+ "█▀█ █ █ █▀▀ █ █ █ █ █ █ █▀▄ █▀▀ █ █ █ ",
+ "▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀",
+];
+
+const LOGO_GRADIENT = ["#00ffff", "#ff00ff"] as const;
+
+export function BrandBanner() {
+ if (LOGO.length === 0) return null;
+
+ const logoWidth = Math.max(...LOGO.map((line) => Array.from(line).length));
+
+ return (
+
+
+
+ {LOGO.map((line, index) => (
+
+ ))}
+
+
+ v{PACKAGE_VERSION}
+
+
+
+
+ );
+}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 149f88ac9..b106d0f8f 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -5,6 +5,8 @@ import { Footer } from "./Footer";
import type { KeyHintProps } from "./ui/key-hint";
export interface LayoutProps {
+ // banner is content rendered above the standard breadcrumb header.
+ banner?: React.ReactNode;
// breadcrumb is passed through to the Header.
breadcrumb: HeaderProps["breadcrumb"];
// description is passed through to the Header, shown dimmed after the breadcrumb.
@@ -18,11 +20,18 @@ export interface LayoutProps {
// Layout is the standard full-screen frame: a breadcrumb Header at the top, a
// KeyHint Footer at the bottom, and a flexible content area in between that grows
// to fill the remaining terminal height.
-export const Layout: React.FC = ({ breadcrumb, description, keyHints, children }) => {
+export const Layout: React.FC = ({
+ banner,
+ breadcrumb,
+ description,
+ keyHints,
+ children,
+}) => {
const { columns, rows } = useWindowSize();
return (
+ {banner}
{children}
diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx
index 1ccd88beb..790282439 100644
--- a/src/components/RouterScreen.test.tsx
+++ b/src/components/RouterScreen.test.tsx
@@ -1,4 +1,5 @@
import { test, expect, describe, afterEach } from "bun:test";
+import { PACKAGE_VERSION } from "../constants";
import { cleanupScreens, renderScreen, tick, waitForText } from "../testing";
afterEach(cleanupScreens);
@@ -32,6 +33,23 @@ describe("menu rendering", () => {
r.unmount();
});
+ test("shows the brand banner only on the root menu", async () => {
+ const logoMarker = "█▀█ █▀▀ █▀▀";
+ const version = `v${PACKAGE_VERSION}`;
+ const root = renderScreen("/agentcore");
+ await waitForText(root.lastFrame, logoMarker);
+
+ expect(root.lastFrame()).toContain(version);
+ root.unmount();
+
+ const nested = renderScreen("/agentcore/harness");
+ await waitForText(nested.lastFrame, "agentcore → harness");
+
+ expect(nested.lastFrame()).not.toContain(logoMarker);
+ expect(nested.lastFrame()).not.toContain(version);
+ nested.unmount();
+ });
+
test("renders the harness subcommands when mounted at the harness path", async () => {
const r = renderScreen("/agentcore/harness");
await waitForText(r.lastFrame, "list");
diff --git a/src/components/RouterScreen.tsx b/src/components/RouterScreen.tsx
index 58d784178..356452510 100644
--- a/src/components/RouterScreen.tsx
+++ b/src/components/RouterScreen.tsx
@@ -43,6 +43,8 @@ interface Option {
}
export interface RouterScreenProps extends ScreenProps {
+ // banner is content shown above the standard screen header.
+ banner?: React.ReactNode;
// path is the screen's command path, e.g. ["agentcore", "harness"]. The first
// segment is the app root; the last is the command whose subcommands are the
// menu options.
@@ -54,7 +56,7 @@ export interface RouterScreenProps extends ScreenProps {
// Command) as navigable options below. Selecting an option routes to that
// subcommand's screen. Subcommands without a screen are listed below a divider
// and open their help instead (see CliOnlyScreen).
-export function RouterScreen({ ctx, path }: RouterScreenProps) {
+export function RouterScreen({ banner, ctx, path }: RouterScreenProps) {
const navigate = useNavigate();
const { isRawModeSupported } = useStdin();
const { exit } = useApp();
@@ -128,6 +130,7 @@ export function RouterScreen({ ctx, path }: RouterScreenProps) {
return (
+ {label}
+
+ );
+}
diff --git a/src/components/ui/badge/index.ts b/src/components/ui/badge/index.ts
new file mode 100644
index 000000000..6a7eb6dfa
--- /dev/null
+++ b/src/components/ui/badge/index.ts
@@ -0,0 +1 @@
+export { Badge, type BadgeProps } from "./Badge";
diff --git a/src/components/ui/gradient-text/GradientText.test.ts b/src/components/ui/gradient-text/GradientText.test.ts
new file mode 100644
index 000000000..8b1914e9d
--- /dev/null
+++ b/src/components/ui/gradient-text/GradientText.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, test } from "bun:test";
+import { interpolateGradientColor } from "./GradientText";
+
+describe("interpolateGradientColor", () => {
+ test("returns the start, midpoint, and end colors", () => {
+ const colors = ["#000000", "#ffffff"] as const;
+
+ expect(interpolateGradientColor(colors, 0)).toBe("#000000");
+ expect(interpolateGradientColor(colors, 0.5)).toBe("#808080");
+ expect(interpolateGradientColor(colors, 1)).toBe("#ffffff");
+ });
+});
diff --git a/src/components/ui/gradient-text/GradientText.tsx b/src/components/ui/gradient-text/GradientText.tsx
new file mode 100644
index 000000000..b5bbe9bfd
--- /dev/null
+++ b/src/components/ui/gradient-text/GradientText.tsx
@@ -0,0 +1,64 @@
+import { Text } from "ink";
+
+export interface GradientTextProps {
+ text: string;
+ colors: readonly [string, ...string[]];
+ span?: number;
+}
+
+interface Rgb {
+ red: number;
+ green: number;
+ blue: number;
+}
+
+function parseHex(color: string): Rgb {
+ const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color);
+ if (!match) throw new Error(`Gradient colors must use six-digit hex values: ${color}`);
+
+ return {
+ red: Number.parseInt(match[1]!, 16),
+ green: Number.parseInt(match[2]!, 16),
+ blue: Number.parseInt(match[3]!, 16),
+ };
+}
+
+function interpolate(from: number, to: number, progress: number): number {
+ return Math.round(from + (to - from) * progress);
+}
+
+function toHex(value: number): string {
+ return value.toString(16).padStart(2, "0");
+}
+
+export function interpolateGradientColor(
+ colors: readonly [string, ...string[]],
+ position: number,
+): string {
+ if (colors.length === 1) return colors[0];
+
+ const scaledPosition = position * (colors.length - 1);
+ const stopIndex = Math.min(Math.floor(scaledPosition), colors.length - 2);
+ const stopProgress = scaledPosition - stopIndex;
+ const from = parseHex(colors[stopIndex]!);
+ const to = parseHex(colors[stopIndex + 1]!);
+
+ return `#${toHex(interpolate(from.red, to.red, stopProgress))}${toHex(
+ interpolate(from.green, to.green, stopProgress),
+ )}${toHex(interpolate(from.blue, to.blue, stopProgress))}`;
+}
+
+export function GradientText({ text, colors, span = Array.from(text).length }: GradientTextProps) {
+ const characters = Array.from(text);
+ const denominator = Math.max(1, span - 1);
+
+ return (
+
+ {characters.map((character, index) => (
+
+ {character}
+
+ ))}
+
+ );
+}
diff --git a/src/components/ui/gradient-text/index.ts b/src/components/ui/gradient-text/index.ts
new file mode 100644
index 000000000..9a7261c0b
--- /dev/null
+++ b/src/components/ui/gradient-text/index.ts
@@ -0,0 +1 @@
+export { GradientText, type GradientTextProps } from "./GradientText";
diff --git a/src/handlers/screen.tsx b/src/handlers/screen.tsx
index c5cdbdedc..420171cb4 100644
--- a/src/handlers/screen.tsx
+++ b/src/handlers/screen.tsx
@@ -1,11 +1,12 @@
import { Text, useApp } from "ink";
import { useEffect } from "react";
import { CommandKey } from "../router";
+import { BrandBanner } from "../components/BrandBanner";
import { RouterScreen } from "../components/RouterScreen";
import type { ScreenProps } from "./types";
export function RootScreen(props: ScreenProps) {
- return ;
+ return } path={["agentcore"]} />;
}
// HelpScreen is the final safety net for a route that does not resolve to an