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
4 changes: 4 additions & 0 deletions src/components/BrandBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const LOGO: string[] = [
"▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀",
];

export const MIN_BANNER_COLUMNS = 80;
export const MIN_BANNER_ROWS = 30;
export const BRAND_BANNER_ROWS = LOGO.length + 1;

export interface BrandBannerProps {
terminalProgram?: string;
theme?: InkUITheme;
Expand Down
46 changes: 41 additions & 5 deletions src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { Box } from "ink";
import { Box, useBoxMetrics, type DOMElement } from "ink";
import { useWindowSize } from "./ui/useWindowSize";
import { Header, type HeaderProps } from "./Header";
import { Footer } from "./Footer";
Expand All @@ -8,34 +8,70 @@ import type { KeyHintProps } from "./ui/key-hint";
export interface LayoutProps {
// banner is content rendered above the standard breadcrumb header.
banner?: React.ReactNode;
// bannerHeight is the number of terminal rows occupied by the banner.
bannerHeight?: number;
// hideBanner suppresses the banner regardless of terminal dimensions.
hideBanner?: boolean;
// bannerMinColumns/bannerMinRows suppress the banner below these dimensions.
bannerMinColumns?: number;
bannerMinRows?: number;
// breadcrumb is passed through to the Header.
breadcrumb: HeaderProps["breadcrumb"];
// description is passed through to the Header, shown dimmed after the breadcrumb.
description?: HeaderProps["description"];
// keyHints is passed through to the Footer (KeyHint) row.
keyHints: KeyHintProps["keys"];
// children fill the content area between the header and footer.
children: React.ReactNode;
children:
| React.ReactNode
| ((dimensions: {
columns: number;
rows: number;
contentRows: number;
bannerVisible: boolean;
}) => React.ReactNode);
}

const DEFAULT_HEADER_ROWS = 2;
const FOOTER_ROWS = 2;

// 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> = ({
banner,
bannerHeight = 0,
hideBanner = false,
bannerMinColumns = 0,
bannerMinRows = 0,
breadcrumb,
description,
keyHints,
children,
}) => {
const { columns, rows } = useWindowSize();
const headerRef = React.useRef<DOMElement>(null);
const { height: measuredHeaderRows, hasMeasured: hasMeasuredHeader } = useBoxMetrics(headerRef);
const bannerVisible =
Boolean(banner) && !hideBanner && columns >= bannerMinColumns && rows >= bannerMinRows;
const headerRows = hasMeasuredHeader ? measuredHeaderRows : DEFAULT_HEADER_ROWS;
const contentRows = Math.max(
0,
rows - headerRows - FOOTER_ROWS - (bannerVisible ? bannerHeight : 0),
);
const content =
typeof children === "function"
? children({ columns, rows, contentRows, bannerVisible })
: children;

return (
<Box width={columns} height={rows} flexDirection="column">
{banner}
<Header breadcrumb={breadcrumb} description={description} />
{bannerVisible ? banner : null}
<Box ref={headerRef} flexShrink={0}>
<Header breadcrumb={breadcrumb} description={description} />
</Box>
<Box flexGrow={1} flexShrink={1} minHeight={0} flexDirection="column">
{children}
{content}
</Box>
<Footer keys={keyHints} />
</Box>
Expand Down
32 changes: 32 additions & 0 deletions src/components/RouterScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ describe("menu rendering", () => {
const entries = menuEntries(frame);
expect(entries.screens).toContain("create");
expect(entries.screens).toContain("eval");
expect(frame.split("\n").filter((line) => line.includes("❯ "))).toHaveLength(1);
expect(new Set([...entries.screens, ...entries.cliOnly]).size).toBe(
entries.screens.length + entries.cliOnly.length,
);
expect(frame).toContain("config");
expect(frame).toContain("read/write global config values");
r.unmount();
Expand Down Expand Up @@ -135,6 +139,20 @@ describe("menu rendering", () => {
nested.unmount();
});

test("hides the brand banner when the terminal is short and restores it when enlarged", async () => {
const version = `v${PACKAGE_VERSION}`;
const r = renderScreen("/agentcore");
await waitForText(r.lastFrame, version);

await r.resize(80, 24);
expect(r.lastFrame()).not.toContain(version);
expect(r.lastFrame()).toContain("[enter] select");

await r.resize(100, 40);
await waitForText(r.lastFrame, version);
r.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 Expand Up @@ -415,6 +433,20 @@ describe("short terminals", () => {
r.unmount();
});

test("accounts for a wrapped header when scrolling a narrow terminal", async () => {
const full = await fullMenu();
const r = renderScreen("/agentcore");
await waitForText(r.lastFrame, "❯ create");
await r.resize(40, ROWS);

for (let i = 0; i < full.names.length; i++) {
if (i > 0) await r.press("down");
expect(r.lastFrame()).toMatch(new RegExp(`❯\\s*${full.names[i]}`));
}

r.unmount();
});

test("a nested menu, without the banner, uses the rows the banner would take", async () => {
const r = renderScreen("/agentcore/harness");
await waitForText(r.lastFrame, "❯ ");
Expand Down
202 changes: 124 additions & 78 deletions src/components/RouterScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, measureElement, useApp, useInput, useStdin, type DOMElement } from "ink";
import { useWindowSize } from "./ui/useWindowSize";
import React, { useContext, useEffect, useMemo, useState } from "react";
import { Box, Text, useApp, useInput, useStdin } from "ink";
import type { Command } from "commander";
import { Navigate, useNavigate } from "react-router";
import {
Expand All @@ -9,17 +8,24 @@ import {
isListedInMenu,
isTuiCommandSupported,
} from "../router";
import {
BRAND_BANNER_ROWS,
MIN_BANNER_COLUMNS,
MIN_BANNER_ROWS,
shouldHideBrandBanner,
} from "./BrandBanner";
import { Layout } from "./Layout";
import { Divider } from "./ui/divider";
import { TextInput } from "./ui/text-input";
import { darkTheme, glyphs } from "./ui/_core.js";
import type { ScreenProps } from "../handlers/types";
import { RegionPinContext } from "../handlers/utils";
import { useScrollWindow } from "./scrollWindow";
import { scrollWindow } from "./scrollWindow";

const theme = darkTheme;
const PLACEHOLDER = "type to choose a command";
const CLI_ONLY_SECTION = "cli";
const FILTER_ROWS = 2;

// rootCommand walks up to the top of the Commander tree.
function rootCommand(c: Command): Command {
Expand Down Expand Up @@ -152,21 +158,6 @@ function CommandMenu({
// match is index 0, which is what a fresh query resets to.
const highlight = Math.min(index, Math.max(0, filtered.length - 1));

// The list gets whatever height the screen leaves it (the root menu adds a
// banner, a long header can wrap), so measure it whenever the terminal
// changes size rather than subtracting a fixed count of chrome rows. The
// list fills that space whatever it holds, so its contents don't matter.
// Until the first measurement the list is clipped to the terminal height.
const { columns, rows } = useWindowSize();
const listRef = useRef<DOMElement>(null);
const [listRows, setListRows] = useState<number>();
useEffect(() => {
if (!listRef.current) return;
setListRows(measureElement(listRef.current).height);
}, [columns, rows]);
const sections = useMemo(() => filtered.map((o) => o.section), [filtered]);
const view = useScrollWindow(sections, highlight, listRows ?? rows);

const base = "/" + path.join("/");

// Width of the name column so descriptions line up (longest name + a gap).
Expand Down Expand Up @@ -213,6 +204,10 @@ function CommandMenu({
return (
<Layout
banner={banner}
bannerHeight={BRAND_BANNER_ROWS}
hideBanner={Boolean(banner) && shouldHideBrandBanner(process.env.TERM_PROGRAM)}
bannerMinColumns={MIN_BANNER_COLUMNS}
bannerMinRows={MIN_BANNER_ROWS}
breadcrumb={path}
description={command.description()}
keyHints={[
Expand All @@ -224,71 +219,122 @@ function CommandMenu({
{ key: "ctrl+c", label: "quit" },
]}
>
<Box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0}>
{/* Filter input. TextInput owns text editing; the highlight resets to
the best match whenever the query changes. */}
<Box paddingX={1} flexShrink={0}>
<TextInput
value={query}
onChange={(v) => {
setQuery(v);
setIndex(0);
}}
placeholder={PLACEHOLDER}
prompt="/ "
focus={Boolean(isRawModeSupported)}
/>
</Box>
{({ columns, contentRows }) => (
<CommandMenuBody
columns={columns}
contentRows={contentRows}
filtered={filtered}
highlight={highlight}
isRawModeSupported={Boolean(isRawModeSupported)}
nameWidth={nameWidth}
query={query}
onQueryChange={(value) => {
setQuery(value);
setIndex(0);
}}
/>
)}
</Layout>
);
}

interface CommandMenuBodyProps {
columns: number;
contentRows: number;
filtered: Option[];
highlight: number;
isRawModeSupported: boolean;
nameWidth: number;
query: string;
onQueryChange: (value: string) => void;
}

function CommandMenuBody({
columns,
contentRows,
filtered,
highlight,
isRawModeSupported,
nameWidth,
query,
onQueryChange,
}: CommandMenuBodyProps) {
const sections = useMemo(() => filtered.map((option) => option.section), [filtered]);
const menuHeight = Math.max(0, contentRows - FILTER_ROWS);
const windowStart = Math.max(0, highlight - Math.floor(menuHeight / 2));
const view = scrollWindow({
sections,
highlight,
start: windowStart,
budget: menuHeight,
});

<Divider />
return (
<Box flexDirection="column" height={contentRows} overflow="hidden">
<Box paddingX={1} height={1} overflow="hidden" flexShrink={0}>
<TextInput
value={query}
onChange={onQueryChange}
placeholder={PLACEHOLDER}
prompt="/ "
focus={isRawModeSupported}
/>
</Box>

{/* Options */}
<Box
ref={listRef}
flexDirection="column"
flexGrow={1}
flexShrink={1}
minHeight={0}
overflow="hidden"
>
{filtered.length === 0 ? (
<Box paddingX={1} flexShrink={0}>
<Text color={theme.colors.text}>No matches</Text>
</Box>
) : (
view.rows.map((row) => {
if (row.kind === "section") {
return <Divider key={`section:${row.title}`} title={row.title} />;
}
if (row.kind !== "item") {
return (
<Box key={row.kind} paddingX={1} flexShrink={0}>
<Text color={theme.colors.muted}>
{` ${row.kind === "more-above" ? "↑" : "↓"} ${row.count} more`}
</Text>
</Box>
);
}
const o = filtered[row.index]!;
const isHl = row.index === highlight;
<Divider />

<Box flexDirection="column" height={menuHeight} overflow="hidden">
{filtered.length === 0 ? (
<Box paddingX={1} height={1} overflow="hidden">
<Text color={theme.colors.text}>No matches</Text>
</Box>
) : (
view.rows.map((row) => {
if (row.kind === "section") {
return <Divider key={`section:${row.title}`} title={row.title} />;
}
if (row.kind !== "item") {
return (
<Box key={o.name} paddingX={1} flexShrink={0}>
<Text color={theme.colors.focus}>{isHl ? `${glyphs.pointer} ` : " "}</Text>
<Text
bold={isHl}
color={
isHl ? theme.colors.focus : o.cliOnly ? theme.colors.muted : theme.colors.text
}
>
{o.name.padEnd(nameWidth)}
<Box key={row.kind} paddingX={1} height={1} overflow="hidden" flexShrink={0}>
<Text color={theme.colors.muted}>
{` ${row.kind === "more-above" ? "↑" : "↓"} ${row.count} more`}
</Text>
<Text color={theme.colors.muted}>{o.description}</Text>
</Box>
);
})
)}
</Box>
}

const option = filtered[row.index]!;
const isHighlighted = row.index === highlight;
return (
<Box
key={option.name}
paddingX={1}
width={columns}
height={1}
overflow="hidden"
flexShrink={0}
>
<Text color={theme.colors.focus}>
{isHighlighted ? `${glyphs.pointer} ` : " "}
</Text>
<Text
bold={isHighlighted}
color={
isHighlighted
? theme.colors.focus
: option.cliOnly
? theme.colors.muted
: theme.colors.text
}
>
{option.name.padEnd(nameWidth)}
</Text>
<Text color={theme.colors.muted}>{option.description}</Text>
</Box>
);
})
)}
</Box>
</Layout>
</Box>
);
}
Loading
Loading