Skip to content
Draft
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
35 changes: 35 additions & 0 deletions src/components/BrandBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box flexDirection="column" flexShrink={0}>
<Box paddingX={1} alignItems="flex-start">
<Box flexDirection="column">
{LOGO.map((line, index) => (
<GradientText key={index} text={line} colors={LOGO_GRADIENT} span={logoWidth} />
))}
</Box>
<Box marginLeft={1}>
<Badge>v{PACKAGE_VERSION}</Badge>
</Box>
</Box>
<Divider />
</Box>
);
}
11 changes: 10 additions & 1 deletion src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<LayoutProps> = ({ breadcrumb, description, keyHints, children }) => {
export const Layout: React.FC<LayoutProps> = ({
banner,
breadcrumb,
description,
keyHints,
children,
}) => {
const { columns, rows } = useWindowSize();

return (
<Box width={columns} height={rows} flexDirection="column">
{banner}
<Header breadcrumb={breadcrumb} description={description} />
<Box flexGrow={1} flexShrink={1} minHeight={0} flexDirection="column">
{children}
Expand Down
18 changes: 18 additions & 0 deletions src/components/RouterScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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");
Expand Down
5 changes: 4 additions & 1 deletion src/components/RouterScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
Expand Down Expand Up @@ -128,6 +130,7 @@ export function RouterScreen({ ctx, path }: RouterScreenProps) {

return (
<Layout
banner={banner}
breadcrumb={path}
description={command.description()}
keyHints={[
Expand Down
30 changes: 30 additions & 0 deletions src/components/ui/badge/Badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import { Box, Text } from "ink";
import { darkTheme, type InkUITheme } from "../_core";

export interface BadgeProps {
children: ReactNode;
theme?: InkUITheme;
}

const borderStyle = {
topLeft: "╭",
top: "─",
topRight: "┐",
right: "│",
bottomRight: "╯",
bottom: "─",
bottomLeft: "└",
left: "│",
} as const;

export function Badge({ children, theme = darkTheme }: BadgeProps) {
const label = typeof children === "string" ? children.toUpperCase() : children;
const color = theme.colors.secondary;

return (
<Box borderStyle={borderStyle} borderColor={color} paddingX={1}>
<Text color={color}>{label}</Text>
</Box>
);
}
1 change: 1 addition & 0 deletions src/components/ui/badge/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Badge, type BadgeProps } from "./Badge";
12 changes: 12 additions & 0 deletions src/components/ui/gradient-text/GradientText.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
64 changes: 64 additions & 0 deletions src/components/ui/gradient-text/GradientText.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Text>
{characters.map((character, index) => (
<Text key={index} color={interpolateGradientColor(colors, index / denominator)}>
{character}
</Text>
))}
</Text>
);
}
1 change: 1 addition & 0 deletions src/components/ui/gradient-text/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { GradientText, type GradientTextProps } from "./GradientText";
3 changes: 2 additions & 1 deletion src/handlers/screen.tsx
Original file line number Diff line number Diff line change
@@ -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 <RouterScreen {...props} path={["agentcore"]} />;
return <RouterScreen {...props} banner={<BrandBanner />} path={["agentcore"]} />;
}

// HelpScreen is the final safety net for a route that does not resolve to an
Expand Down
Loading