From ac434ef04fa226b4a9885b3357b86cc0097fb43a Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 30 Sep 2026 21:30:23 +0000 Subject: [PATCH] fix(tui): use consistent colors for CLI-only commands --- src/components/RouterScreen.test.tsx | 47 ++++++++++++++++++++++++++++ src/components/RouterScreen.tsx | 14 ++------- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index 58518cabe..7af18b3b4 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 { spawnSync } from "node:child_process"; import { PACKAGE_VERSION } from "../constants"; import { cleanupScreens, @@ -63,6 +64,52 @@ function menuGroups(frame: string): { title: string | undefined; names: string[] // rendered frames — behavior a user would see, not internal state. describe("menu rendering", () => { + test("CLI-only command names use the same white and focused colors as other commands", () => { + // A separate process keeps ANSI enabled without changing the plain-text screen tests. + const result = spawnSync( + process.execPath, + [ + "--eval", + ` + import { renderScreen, waitForText } from "./src/testing/renderScreen.tsx"; + const frames = []; + for (const [path, selected] of [ + ["/agentcore", "feedback"], + ["/agentcore/harness", "logs"], + ]) { + const screen = renderScreen(path); + await waitForText(screen.lastFrame, selected); + const idle = screen.lastFrame(); + await screen.write(selected); + frames.push({ idle, focused: screen.lastFrame(), selected }); + screen.unmount(); + } + process.stdout.write(JSON.stringify(frames)); + `, + ], + { + cwd: new URL("../../", import.meta.url), + env: { ...process.env, FORCE_COLOR: "3", NO_COLOR: undefined, WT_SESSION: "bun-test" }, + timeout: 10_000, + encoding: "utf8", + }, + ); + expect(result.status).toBe(0); + const frames = JSON.parse(result.stdout) as { + idle: string; + focused: string; + selected: string; + }[]; + for (const [index, names] of [ + ["runtime", "feedback", "config", "update"], + ["get", "update", "logs", "traces"], + ].entries()) { + const frame = frames[index]!; + for (const name of names) expect(frame.idle).toContain(`\u001b[37m${name}`); + expect(frame.focused).toContain(`\u001b[36m${glyphs.pointer} \u001b[1m${frame.selected}`); + } + }); + test("lists the current command's subcommands with their descriptions", async () => { const r = renderScreen("/agentcore"); await waitForText(r.lastFrame, "No project detected"); diff --git a/src/components/RouterScreen.tsx b/src/components/RouterScreen.tsx index 5fff2fe7a..47bea52c2 100644 --- a/src/components/RouterScreen.tsx +++ b/src/components/RouterScreen.tsx @@ -64,9 +64,6 @@ interface Option { description: string; hint?: string; notice?: string; - // cliOnly marks a subcommand without a screen; it is listed under a divider - // and opens its help instead. - cliOnly: boolean; // section is the divider title this option is listed under, if any. section?: string; } @@ -155,14 +152,13 @@ function CommandMenu({ description: c.description(), hint: optionHints?.[c.name()], notice: optionNotices?.[c.name()], - cliOnly, section: belongsToCliSection ? CLI_ONLY_SECTION : sectionOf(index), }; }); const actualNames = new Set(actual.map((option) => option.name)); const tuiOnly = tuiOnlyCommands .filter((option) => !actualNames.has(option.name)) - .map((option): Option => ({ ...option, hint: optionHints?.[option.name], cliOnly: false })); + .map((option): Option => ({ ...option, hint: optionHints?.[option.name] })); return [ ...tuiOnly, ...actual.filter((option) => option.section !== CLI_ONLY_SECTION), @@ -362,13 +358,7 @@ function CommandMenuBody({ {option.name.padEnd(nameWidth)}