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