From e167f358d1632ff85f10a4385c3c9d01a39e5516 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 23 Mar 2026 15:36:36 -0400 Subject: [PATCH] ui: fit menu dropdowns to their contents --- src/ui/App.tsx | 1 + src/ui/components/chrome/MenuDropdown.tsx | 15 +++++++----- src/ui/components/chrome/menu.ts | 6 ++--- test/ui-components.test.tsx | 28 +++++++++++++++++++++++ 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index f213338d..a9d2c404 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -874,6 +874,7 @@ function AppShell({ activeMenuItemIndex={activeMenuItemIndex} activeMenuSpec={activeMenuSpec} activeMenuWidth={activeMenuWidth} + terminalWidth={terminal.width} theme={activeTheme} onHoverItem={setActiveMenuItemIndex} onSelectItem={(entry) => { diff --git a/src/ui/components/chrome/MenuDropdown.tsx b/src/ui/components/chrome/MenuDropdown.tsx index a885b660..7ce508fd 100644 --- a/src/ui/components/chrome/MenuDropdown.tsx +++ b/src/ui/components/chrome/MenuDropdown.tsx @@ -39,6 +39,7 @@ export function MenuDropdown({ activeMenuItemIndex, activeMenuSpec, activeMenuWidth, + terminalWidth, theme, onHoverItem, onSelectItem, @@ -48,17 +49,21 @@ export function MenuDropdown({ activeMenuItemIndex: number; activeMenuSpec: MenuSpec; activeMenuWidth: number; + terminalWidth: number; theme: AppTheme; onHoverItem: (index: number) => void; onSelectItem: (entry: Extract) => void; }) { + const clampedWidth = Math.min(activeMenuWidth, Math.max(22, terminalWidth - 2)); + const clampedLeft = Math.max(1, Math.min(activeMenuSpec.left, terminalWidth - clampedWidth - 1)); + return ( - - {padText("-".repeat(activeMenuWidth - 4), activeMenuWidth - 2)} - + {padText("-".repeat(clampedWidth - 4), clampedWidth - 2)} ) : ( onHoverItem(index)} onMouseUp={() => onSelectItem(entry)} > - {renderMenuLine(entry, activeMenuWidth - 2, theme, activeMenuItemIndex === index)} + {renderMenuLine(entry, clampedWidth - 2, theme, activeMenuItemIndex === index)} ), )} diff --git a/src/ui/components/chrome/menu.ts b/src/ui/components/chrome/menu.ts index 3af4ea4e..16cb779f 100644 --- a/src/ui/components/chrome/menu.ts +++ b/src/ui/components/chrome/menu.ts @@ -70,11 +70,11 @@ function menuEntryText(entry: Extract) { return `${check}${entry.label}${hint}`; } -/** Compute a dropdown width that fits its longest entry with a small floor. */ +/** Compute a dropdown content width that fits its longest entry with a little breathing room. */ export function menuWidth(entries: MenuEntry[]) { return Math.max( - 18, - ...entries.map((entry) => (entry.kind === "separator" ? 6 : menuEntryText(entry).length)), + 20, + ...entries.map((entry) => (entry.kind === "separator" ? 6 : menuEntryText(entry).length + 2)), ); } diff --git a/test/ui-components.test.tsx b/test/ui-components.test.tsx index 7b2a4e68..6fb0b42d 100644 --- a/test/ui-components.test.tsx +++ b/test/ui-components.test.tsx @@ -593,6 +593,7 @@ describe("UI components", () => { activeMenuItemIndex={0} activeMenuSpec={{ id: "view", left: 2, width: 6, label: "View" }} activeMenuWidth={24} + terminalWidth={30} theme={theme} onHoverItem={() => {}} onSelectItem={() => {}} @@ -613,6 +614,33 @@ describe("UI components", () => { expect(frame).toContain("m"); }); + test("MenuDropdown repositions wide menus to stay inside the terminal", async () => { + const theme = resolveTheme("midnight", null); + const frame = await captureFrame( + {} }, + { kind: "item", label: "Previous annotated file", action: () => {} }, + ]} + activeMenuItemIndex={0} + activeMenuSpec={{ id: "agent", left: 22, width: 7, label: "Agent" }} + activeMenuWidth={30} + terminalWidth={34} + theme={theme} + onHoverItem={() => {}} + onSelectItem={() => {}} + />, + 34, + 6, + ); + + expect(frame).toContain("Next annotated file"); + expect(frame).toContain("Previous annotated file"); + expect(frame).toContain("┐"); + expect(frame).toContain("┘"); + }); + test("StatusBar renders filter mode affordance", async () => { const theme = resolveTheme("midnight", null); const frame = await captureFrame(