diff --git a/.changeset/menu-bar-toggle.md b/.changeset/menu-bar-toggle.md new file mode 100644 index 00000000..a099bc76 --- /dev/null +++ b/.changeset/menu-bar-toggle.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add a configurable menu bar toggle so keyboard-driven reviews can reclaim one row of terminal space. diff --git a/README.md b/README.md index 761947a2..964d2caa 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ watch = false exclude_untracked = false line_numbers = true wrap_lines = false +menu_bar = true agent_notes = false transparent_background = false ``` diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 7715ea5a..ac3a7f9f 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -75,6 +75,7 @@ describe("config resolution", () => { [ 'theme = "github-light-default"', "wrap_lines = true", + "menu_bar = false", "", "[pager]", "hunk_headers = false", @@ -93,6 +94,7 @@ describe("config resolution", () => { theme: "github-light-default", lineNumbers: false, wrapLines: true, + menuBar: false, hunkHeaders: false, agentNotes: true, transparentBackground: true, @@ -485,6 +487,7 @@ describe("config resolution", () => { 'theme = "github-light-default"', "line_numbers = false", "wrap_lines = true", + "menu_bar = false", "hunk_headers = false", "agent_notes = true", "copy_decorations = false", @@ -511,6 +514,7 @@ describe("config resolution", () => { expect(bootstrap.initialTheme).toBe("github-light-default"); expect(bootstrap.initialShowLineNumbers).toBe(false); expect(bootstrap.initialWrapLines).toBe(true); + expect(bootstrap.initialShowMenuBar).toBe(false); expect(bootstrap.initialShowHunkHeaders).toBe(false); expect(bootstrap.initialShowAgentNotes).toBe(true); expect(bootstrap.initialCopyDecorations).toBe(false); diff --git a/src/core/config.ts b/src/core/config.ts index b3750366..fa261289 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -70,6 +70,7 @@ const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = { showLineNumbers: true, wrapLines: false, showHunkHeaders: true, + showMenuBar: true, showAgentNotes: false, copyDecorations: false, }; @@ -236,6 +237,7 @@ function readConfigPreferences(source: Record): CommonOptions { lineNumbers: normalizeBoolean(source.line_numbers), wrapLines: normalizeBoolean(source.wrap_lines), hunkHeaders: normalizeBoolean(source.hunk_headers), + menuBar: normalizeBoolean(source.menu_bar), agentNotes: normalizeBoolean(source.agent_notes), copyDecorations: normalizeBoolean(source.copy_decorations), transparentBackground: @@ -259,6 +261,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti lineNumbers: overrides.lineNumbers ?? base.lineNumbers, wrapLines: overrides.wrapLines ?? base.wrapLines, hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders, + menuBar: overrides.menuBar ?? base.menuBar, agentNotes: overrides.agentNotes ?? base.agentNotes, copyDecorations: overrides.copyDecorations ?? base.copyDecorations, transparentBackground: overrides.transparentBackground ?? base.transparentBackground, @@ -325,6 +328,7 @@ export function resolveConfiguredCliInput( lineNumbers: DEFAULT_VIEW_PREFERENCES.showLineNumbers, wrapLines: DEFAULT_VIEW_PREFERENCES.wrapLines, hunkHeaders: DEFAULT_VIEW_PREFERENCES.showHunkHeaders, + menuBar: DEFAULT_VIEW_PREFERENCES.showMenuBar, agentNotes: DEFAULT_VIEW_PREFERENCES.showAgentNotes, copyDecorations: DEFAULT_VIEW_PREFERENCES.copyDecorations, transparentBackground: false, @@ -355,6 +359,7 @@ export function resolveConfiguredCliInput( lineNumbers: resolvedOptions.lineNumbers ?? DEFAULT_VIEW_PREFERENCES.showLineNumbers, wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines, hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders, + menuBar: resolvedOptions.menuBar ?? DEFAULT_VIEW_PREFERENCES.showMenuBar, agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes, copyDecorations: resolvedOptions.copyDecorations ?? DEFAULT_VIEW_PREFERENCES.copyDecorations, transparentBackground: resolvedOptions.transparentBackground ?? false, diff --git a/src/core/loaders.ts b/src/core/loaders.ts index d6340ec5..9c7e0a0e 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -458,6 +458,7 @@ export async function loadAppBootstrap( initialShowLineNumbers: input.options.lineNumbers ?? true, initialWrapLines: input.options.wrapLines ?? false, initialShowHunkHeaders: input.options.hunkHeaders ?? true, + initialShowMenuBar: input.options.menuBar ?? true, initialShowAgentNotes: input.options.agentNotes ?? false, initialCopyDecorations: input.options.copyDecorations ?? false, }; diff --git a/src/core/types.ts b/src/core/types.ts index 9da14f08..4c6c5c95 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -90,6 +90,7 @@ export interface CommonOptions { lineNumbers?: boolean; wrapLines?: boolean; hunkHeaders?: boolean; + menuBar?: boolean; agentNotes?: boolean; copyDecorations?: boolean; transparentBackground?: boolean; @@ -155,6 +156,7 @@ export interface PersistedViewPreferences { showLineNumbers: boolean; wrapLines: boolean; showHunkHeaders: boolean; + showMenuBar: boolean; showAgentNotes: boolean; copyDecorations: boolean; } @@ -364,6 +366,7 @@ export interface AppBootstrap { initialShowLineNumbers?: boolean; initialWrapLines?: boolean; initialShowHunkHeaders?: boolean; + initialShowMenuBar?: boolean; initialShowAgentNotes?: boolean; initialCopyDecorations?: boolean; } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 3b291313..8b5b0985 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -67,6 +67,7 @@ function withCurrentViewOptions( showAgentNotes: boolean; showHunkHeaders: boolean; showLineNumbers: boolean; + showMenuBar: boolean; wrapLines: boolean; }, ): CliInput { @@ -79,6 +80,7 @@ function withCurrentViewOptions( agentNotes: view.showAgentNotes, hunkHeaders: view.showHunkHeaders, lineNumbers: view.showLineNumbers, + menuBar: view.showMenuBar, wrapLines: view.wrapLines, }, }; @@ -134,6 +136,7 @@ export function App({ const [copyDecorations, setCopyDecorations] = useState(bootstrap.initialCopyDecorations ?? false); const [codeHorizontalOffset, setCodeHorizontalOffset] = useState(0); const [showHunkHeaders, setShowHunkHeaders] = useState(bootstrap.initialShowHunkHeaders ?? true); + const [showMenuBar, setShowMenuBar] = useState(bootstrap.initialShowMenuBar ?? true); const [themeSelectorState, setThemeSelectorState] = useState({ open: false, selectedIndex: 0, @@ -504,6 +507,11 @@ export function App({ setShowHunkHeaders((current) => !current); }; + /** Toggle the top menu bar while keeping F10 menu navigation available. */ + const toggleMenuBar = () => { + setShowMenuBar((current) => !current); + }; + const canRefreshCurrentInput = canReloadInput(bootstrap.input); const watchEnabled = Boolean(bootstrap.input.options.watch && canRefreshCurrentInput); @@ -519,6 +527,7 @@ export function App({ showAgentNotes, showHunkHeaders, showLineNumbers, + showMenuBar, wrapLines, }); @@ -540,6 +549,7 @@ export function App({ showAgentNotes, showHunkHeaders, showLineNumbers, + showMenuBar, themeId, wrapLines, ]); @@ -743,6 +753,7 @@ export function App({ showHelp, showHunkHeaders, showLineNumbers, + showMenuBar, renderSidebar, toggleCopyDecorations, toggleAgentNotes, @@ -751,6 +762,7 @@ export function App({ toggleHelp, toggleHunkHeaders, toggleLineNumbers, + toggleMenuBar, toggleLineWrap, toggleSidebar, triggerEditSelectedFile, @@ -774,12 +786,14 @@ export function App({ showHelp, showHunkHeaders, showLineNumbers, + showMenuBar, renderSidebar, toggleAgentNotes, toggleFocusArea, toggleHelp, toggleHunkHeaders, toggleLineNumbers, + toggleMenuBar, toggleLineWrap, toggleSidebar, triggerEditSelectedFile, @@ -839,6 +853,7 @@ export function App({ toggleHelp, toggleHunkHeaders, toggleLineNumbers, + toggleMenuBar, toggleLineWrap, toggleSidebar, triggerEditSelectedFile, @@ -906,7 +921,7 @@ export function App({ // this in lockstep with the body container's paddingLeft and the sidebar render branch below. const diffPaneScreenLeft = bodyPadding / 2 + (renderSidebar ? clampedSidebarWidth + DIVIDER_WIDTH : 0); - const diffPaneScreenTop = pagerMode ? 0 : 1; + const diffPaneScreenTop = pagerMode || !showMenuBar ? 0 : 1; return ( - {!pagerMode ? ( + {!pagerMode && showMenuBar ? ( line.trim().length > 0) ?? ""; +} + function createMockHostClient({ cwd = process.cwd(), repoRoot = process.cwd(), @@ -722,6 +726,73 @@ describe("App interactions", () => { } }); + test("Shift-M hides the menu bar without disabling F10 menus", async () => { + const setup = await testRender(, { + width: 240, + height: 24, + }); + + try { + await flush(setup); + + let frame = setup.captureCharFrame(); + expect(frame).toContain("File View Navigate Agent Help"); + + await act(async () => { + await setup.mockInput.pressKey("m", { shift: true }); + }); + await flush(setup); + + frame = setup.captureCharFrame(); + expect(frame).not.toContain("File View Navigate Agent Help"); + expect(firstNonEmptyLine(frame)).not.toContain("─"); + + await act(async () => { + await setup.mockInput.pressKey("F10"); + }); + frame = await waitForFrame(setup, (nextFrame) => + nextFrame.includes("Toggle files/filter focus"), + ); + expect(frame).toContain("Toggle files/filter focus"); + expect(frame).not.toContain("File View Navigate Agent Help"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("configured hidden menu bar starts hidden while menus remain keyboard-accessible", async () => { + const setup = await testRender( + , + { + width: 240, + height: 24, + }, + ); + + try { + await flush(setup); + + let frame = setup.captureCharFrame(); + expect(frame).not.toContain("File View Navigate Agent Help"); + expect(firstNonEmptyLine(frame)).not.toContain("─"); + + await act(async () => { + await setup.mockInput.pressKey("F10"); + }); + frame = await waitForFrame(setup, (nextFrame) => + nextFrame.includes("Toggle files/filter focus"), + ); + expect(frame).toContain("Toggle files/filter focus"); + expect(frame).not.toContain("File View Navigate Agent Help"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("theme shortcut opens a selector and Enter applies the highlighted theme", async () => { const setup = await testRender(, { width: 240, diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx index 629f8de4..4e3d1835 100644 --- a/src/ui/components/chrome/HelpDialog.tsx +++ b/src/ui/components/chrome/HelpDialog.tsx @@ -47,7 +47,7 @@ export function HelpDialog({ ["s / t", "sidebar / theme selector"], ["a", "toggle AI notes"], ["z", "toggle unchanged context"], - ["l / w / m", "lines / wrap / metadata"], + ["l / w / m / M", "lines / wrap / metadata / menu"], ["e", "open file in $EDITOR"], ], }, diff --git a/src/ui/components/chrome/MenuDropdown.tsx b/src/ui/components/chrome/MenuDropdown.tsx index 7ce508fd..b13eff03 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, + top = 1, terminalWidth, theme, onHoverItem, @@ -49,6 +50,7 @@ export function MenuDropdown({ activeMenuItemIndex: number; activeMenuSpec: MenuSpec; activeMenuWidth: number; + top?: number; terminalWidth: number; theme: AppTheme; onHoverItem: (index: number) => void; @@ -61,7 +63,7 @@ export function MenuDropdown({ void; onViewportCenteredHunkChange?: (fileId: string, hunkIndex: number) => void; }) { + const renderTopChrome = showTopChrome ?? !pagerMode; const renderer = useRenderer(); const mouseWheelScrollAcceleration = useMemo( () => createReviewMouseWheelScrollAcceleration(), @@ -1728,12 +1731,14 @@ export function DiffPane({ ; selectedFileId?: string; + showTopChrome?: boolean; textWidth: number; theme: AppTheme; width: number; @@ -96,12 +98,12 @@ export function SidebarPane({ { "s / t sidebar / theme", "a toggle AI notes", "z toggle unchanged context", - "l / w / m lines / wrap / metadata", + "l / w / m / M lines / wrap / metadata / menu", "e open file in $EDITOR", "Review", "/ focus file filter", diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index 62669531..1f09de60 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -42,6 +42,14 @@ function isUppercaseGKey(key: KeyEvent) { ); } +/** Detect Shift-M without stealing the lowercase hunk metadata toggle. */ +function isUppercaseMKey(key: KeyEvent) { + return ( + (key.sequence === "M" && !key.option && !key.ctrl && !key.meta) || + (key.name === "m" && key.shift && !key.option && !key.ctrl && !key.meta) + ); +} + export interface UseAppKeyboardShortcutsOptions { activeMenuId: MenuId | null; activateCurrentMenuItem: () => void; @@ -77,6 +85,7 @@ export interface UseAppKeyboardShortcutsOptions { toggleHelp: () => void; toggleHunkHeaders: () => void; toggleLineNumbers: () => void; + toggleMenuBar: () => void; toggleLineWrap: () => void; themeSelectorOpen: boolean; toggleSidebar: () => void; @@ -120,6 +129,7 @@ export function useAppKeyboardShortcuts({ toggleHelp, themeSelectorOpen, toggleHunkHeaders, + toggleMenuBar, triggerEditSelectedFile, toggleLineNumbers, toggleLineWrap, @@ -520,6 +530,11 @@ export function useAppKeyboardShortcuts({ return; } + if (isUppercaseMKey(key)) { + runAndCloseMenu(toggleMenuBar); + return; + } + if (key.name === "m" || key.sequence === "m") { runAndCloseMenu(toggleHunkHeaders); return; diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts index eaf2eaaa..6dcadf90 100644 --- a/src/ui/lib/appMenus.ts +++ b/src/ui/lib/appMenus.ts @@ -17,6 +17,7 @@ export interface BuildAppMenusOptions { showHelp: boolean; showHunkHeaders: boolean; showLineNumbers: boolean; + showMenuBar: boolean; renderSidebar: boolean; toggleCopyDecorations: () => void; toggleAgentNotes: () => void; @@ -25,6 +26,7 @@ export interface BuildAppMenusOptions { toggleHelp: () => void; toggleHunkHeaders: () => void; toggleLineNumbers: () => void; + toggleMenuBar: () => void; toggleLineWrap: () => void; toggleSidebar: () => void; triggerEditSelectedFile: () => void; @@ -48,6 +50,7 @@ export function buildAppMenus({ showHelp, showHunkHeaders, showLineNumbers, + showMenuBar, renderSidebar, toggleCopyDecorations, toggleAgentNotes, @@ -56,6 +59,7 @@ export function buildAppMenus({ toggleHelp, toggleHunkHeaders, toggleLineNumbers, + toggleMenuBar, toggleLineWrap, toggleSidebar, triggerEditSelectedFile, @@ -133,6 +137,13 @@ export function buildAppMenus({ checked: renderSidebar, action: toggleSidebar, }, + { + kind: "item", + label: "Menu bar", + hint: "M", + checked: showMenuBar, + action: toggleMenuBar, + }, { kind: "separator" }, { kind: "item", diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 5749ae1f..098ea5cb 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -145,6 +145,7 @@ describe("ui helpers", () => { showHelp: false, showHunkHeaders: false, showLineNumbers: true, + showMenuBar: true, renderSidebar: false, toggleCopyDecorations: () => {}, toggleAgentNotes: () => {}, @@ -153,6 +154,7 @@ describe("ui helpers", () => { toggleHelp: () => {}, toggleHunkHeaders: () => {}, toggleLineNumbers: () => {}, + toggleMenuBar: () => {}, toggleLineWrap: () => {}, toggleSidebar: () => {}, triggerEditSelectedFile: () => {}, @@ -182,7 +184,14 @@ describe("ui helpers", () => { entry.kind === "item" && Boolean(entry.checked), ) .map((entry) => entry.label), - ).toEqual(["Stacked view", "Agent notes", "Line numbers", "Line wrapping", "Copy decorations"]); + ).toEqual([ + "Stacked view", + "Menu bar", + "Agent notes", + "Line numbers", + "Line wrapping", + "Copy decorations", + ]); expect( menus.view .filter((entry): entry is Extract => entry.kind === "item")