diff --git a/.changeset/responsive-menu-labels.md b/.changeset/responsive-menu-labels.md
new file mode 100644
index 000000000..847dcc93c
--- /dev/null
+++ b/.changeset/responsive-menu-labels.md
@@ -0,0 +1,5 @@
+---
+"hunkdiff": patch
+---
+
+Compact menu-bar labels before truncating the current review title on narrow terminals.
diff --git a/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx
index d709866ca..464ae4a01 100644
--- a/packages/hunk/src/ui/App.tsx
+++ b/packages/hunk/src/ui/App.tsx
@@ -47,6 +47,7 @@ import type { ReloadedSessionResult, ReloadSessionOptions } from "../session/typ
import { HelpDialog } from "./components/chrome/HelpDialog";
import { MenuDropdown } from "./components/chrome/MenuDropdown";
import { MenuBar } from "./components/chrome/MenuBar";
+import { responsiveActiveMenuSpec, responsiveMenuSpecs } from "./components/chrome/menu";
import { ConfirmDialog, confirmDialogHeight } from "./components/chrome/ConfirmDialog";
import { ExtensionDialog } from "./components/chrome/ExtensionDialog";
import { ViewPreferenceQuitDialog } from "./components/chrome/ViewPreferenceQuitDialog";
@@ -1272,7 +1273,6 @@ export function App({
activeMenuEntries,
activeMenuId,
activeMenuItemIndex,
- activeMenuSpec,
activeMenuWidth,
activateCurrentMenuItem,
closeMenu,
@@ -1337,6 +1337,10 @@ export function App({
0,
);
const topTitle = `${bootstrap.changeset.title} ${changedFileCount} ${changedFileLabel} +${totalAdditions} -${totalDeletions}`;
+ const responsiveMenuLayout = responsiveMenuSpecs(menuSpecs, terminal.width, topTitle);
+ const activeMenuSpec = activeMenuId
+ ? responsiveActiveMenuSpec(responsiveMenuLayout, activeMenuId)
+ : undefined;
const diffHeaderStatsWidth = maxFileHeaderStatsWidth(filteredFiles);
const diffHeaderLabelWidth = Math.max(0, diffContentWidth - diffHeaderStatsWidth - 1);
const diffSeparatorWidth = Math.max(0, diffContentWidth - 2);
diff --git a/packages/hunk/src/ui/AppHost.responsive.test.tsx b/packages/hunk/src/ui/AppHost.responsive.test.tsx
index f0bfc7ccf..664abfa75 100644
--- a/packages/hunk/src/ui/AppHost.responsive.test.tsx
+++ b/packages/hunk/src/ui/AppHost.responsive.test.tsx
@@ -139,6 +139,14 @@ describe("responsive app", () => {
expect(frame).not.toContain("packages/visual-studio-code-.");
});
+ test("the menu bar compacts labels before truncating the review title", async () => {
+ const frame = await captureFrameForBootstrap(createBootstrap(), 70, 12);
+
+ expect(frame).toContain("File View Nav Agent ?");
+ expect(frame).toContain("repo working tree 2 files +3 -2");
+ expect(frame).not.toContain("Navigate");
+ });
+
test("View menu sidebar checkmark follows actual medium-viewport visibility", async () => {
const setup = await testRender(, {
width: 180,
diff --git a/packages/hunk/src/ui/components/chrome/MenuBar.tsx b/packages/hunk/src/ui/components/chrome/MenuBar.tsx
index fed171f30..2e312ee95 100644
--- a/packages/hunk/src/ui/components/chrome/MenuBar.tsx
+++ b/packages/hunk/src/ui/components/chrome/MenuBar.tsx
@@ -20,7 +20,7 @@ export function MenuBar({
onHoverMenu: (menuId: MenuId) => void;
onToggleMenu: (menuId: MenuId) => void;
}) {
- const responsive = responsiveMenuSpecs(menuSpecs, terminalWidth);
+ const responsive = responsiveMenuSpecs(menuSpecs, terminalWidth, topTitle);
const visibleMenuSpecs = responsive.visible;
const hiddenMenuIds = new Set(responsive.hidden.map((menu) => menu.id));
const activeHiddenIndex = responsive.hidden.findIndex((menu) => menu.id === activeMenuId);
diff --git a/packages/hunk/src/ui/components/chrome/menu.ts b/packages/hunk/src/ui/components/chrome/menu.ts
index 3191fa429..de3ffa17e 100644
--- a/packages/hunk/src/ui/components/chrome/menu.ts
+++ b/packages/hunk/src/ui/components/chrome/menu.ts
@@ -44,6 +44,12 @@ const MENU_LABELS: Record = {
help: "Help",
};
+const COMPACT_MENU_LABELS: Partial> = {
+ navigate: "Nav",
+ extensions: "Ext",
+ help: "?",
+};
+
export const MENU_ORDER = Object.keys(MENU_LABELS) as MenuId[];
/** The entries of one menu, or none when the session does not show it. */
@@ -77,18 +83,48 @@ export function buildMenuSpecs(menus: AppMenus) {
);
}
-/** Fit a shared ordered menu model into one bar and retain hidden menus behind overflow. */
-export function responsiveMenuSpecs(menuSpecs: readonly MenuSpec[], terminalWidth: number) {
+/** Reflow menu positions using compact labels where they provide meaningful space. */
+function compactMenuSpecs(menuSpecs: readonly MenuSpec[]) {
+ return menuSpecs.reduce((items, menu) => {
+ const previous = items.at(-1);
+ const label = COMPACT_MENU_LABELS[menu.id] ?? menu.label;
+ items.push({
+ ...menu,
+ left: previous ? previous.left + previous.width : 1,
+ width: label.length + 2,
+ label,
+ });
+ return items;
+ }, []);
+}
+
+export interface ResponsiveMenuLayout {
+ visible: MenuSpec[];
+ hidden: MenuSpec[];
+ overflowLeft: number | null;
+}
+
+/** Fit menus beside the title, compacting labels before retaining hidden menus behind overflow. */
+export function responsiveMenuSpecs(
+ menuSpecs: readonly MenuSpec[],
+ terminalWidth: number,
+ topTitle = "",
+): ResponsiveMenuLayout {
const rightEdge = Math.max(1, terminalWidth - 1);
- const allVisible = menuSpecs.filter((menu) => menu.left + menu.width <= rightEdge);
- if (allVisible.length === menuSpecs.length) {
+ const fullMenusFit = menuSpecs.every((menu) => menu.left + menu.width <= rightEdge);
+ const titleFits = measureTextWidth(topTitle) <= menuBarTitleWidth(menuSpecs, terminalWidth);
+ const displaySpecs = fullMenusFit && titleFits ? [...menuSpecs] : compactMenuSpecs(menuSpecs);
+ const allVisible = displaySpecs.filter((menu) => menu.left + menu.width <= rightEdge);
+ if (allVisible.length === displaySpecs.length) {
return { visible: allVisible, hidden: [] as MenuSpec[], overflowLeft: null };
}
const overflowWidth = 3;
- const visible = menuSpecs.filter((menu) => menu.left + menu.width + overflowWidth <= rightEdge);
+ const visible = displaySpecs.filter(
+ (menu) => menu.left + menu.width + overflowWidth <= rightEdge,
+ );
const visibleIds = new Set(visible.map((menu) => menu.id));
- const hidden = menuSpecs.filter((menu) => !visibleIds.has(menu.id));
+ const hidden = displaySpecs.filter((menu) => !visibleIds.has(menu.id));
const previous = visible.at(-1);
return {
visible,
@@ -97,6 +133,20 @@ export function responsiveMenuSpecs(menuSpecs: readonly MenuSpec[], terminalWidt
};
}
+/** Position an open dropdown beneath its visible label or the overflow control that represents it. */
+export function responsiveActiveMenuSpec(
+ layout: ResponsiveMenuLayout,
+ activeMenuId: MenuId,
+): MenuSpec | undefined {
+ const visible = layout.visible.find((menu) => menu.id === activeMenuId);
+ if (visible) return visible;
+
+ const hidden = layout.hidden.find((menu) => menu.id === activeMenuId);
+ return hidden && layout.overflowLeft !== null
+ ? { ...hidden, left: layout.overflowLeft, width: 3 }
+ : hidden;
+}
+
/** Find the next selectable menu item, skipping separators. */
export function nextMenuItemIndex(entries: MenuEntry[], currentIndex: number, delta: number) {
if (entries.length === 0) {
diff --git a/packages/hunk/src/ui/lib/ui-lib.test.ts b/packages/hunk/src/ui/lib/ui-lib.test.ts
index 657183955..91766cd5c 100644
--- a/packages/hunk/src/ui/lib/ui-lib.test.ts
+++ b/packages/hunk/src/ui/lib/ui-lib.test.ts
@@ -9,6 +9,7 @@ import {
menuBoxHeight,
menuWidth,
nextMenuItemIndex,
+ responsiveActiveMenuSpec,
responsiveMenuSpecs,
type MenuEntry,
} from "../components/chrome/menu";
@@ -187,6 +188,64 @@ describe("ui helpers", () => {
expect(layout.overflowLeft).toBe(13);
});
+ test("responsive menus compact labels before truncating the changeset title", () => {
+ const item: MenuEntry = { kind: "item", label: "One", action: () => {} };
+ const specs = buildMenuSpecs({
+ file: [item],
+ view: [item],
+ navigate: [item],
+ agent: [item],
+ extensions: [item],
+ help: [item],
+ });
+ const title = "repo working tree 2 files +3 -2";
+
+ const compact = responsiveMenuSpecs(specs, 80, title);
+ expect(compact.visible.map(({ label }) => label)).toEqual([
+ "File",
+ "View",
+ "Nav",
+ "Agent",
+ "Ext",
+ "?",
+ ]);
+ expect(compact.hidden).toEqual([]);
+ expect(menuBarTitleWidth(compact.visible, 80)).toBeGreaterThanOrEqual(measureTextWidth(title));
+
+ const full = responsiveMenuSpecs(specs, 100, title);
+ expect(full.visible.map(({ label }) => label)).toEqual([
+ "File",
+ "View",
+ "Navigate",
+ "Agent",
+ "Extensions",
+ "Help",
+ ]);
+ });
+
+ test("responsive dropdowns follow compact labels and hidden-menu overflow", () => {
+ const item: MenuEntry = { kind: "item", label: "One", action: () => {} };
+ const specs = buildMenuSpecs({
+ file: [item],
+ view: [item],
+ navigate: [item],
+ commit: [item],
+ help: [item],
+ });
+
+ const compact = responsiveMenuSpecs(specs, 36, "history");
+ expect(responsiveActiveMenuSpec(compact, "navigate")).toMatchObject({
+ label: "Nav",
+ left: 13,
+ });
+
+ const overflow = responsiveMenuSpecs(specs, 20, "history");
+ expect(responsiveActiveMenuSpec(overflow, "commit")).toMatchObject({
+ left: overflow.overflowLeft,
+ width: 3,
+ });
+ });
+
test("menuBarTitleWidth cedes title space to the menus the bar shows", () => {
const item: MenuEntry = { kind: "item", label: "One", action: () => {} };
const base = { file: [item], view: [item], navigate: [item], agent: [item], help: [item] };
diff --git a/packages/hunk/src/ui/log/LogApp.tsx b/packages/hunk/src/ui/log/LogApp.tsx
index e7786c201..b2c8b0424 100644
--- a/packages/hunk/src/ui/log/LogApp.tsx
+++ b/packages/hunk/src/ui/log/LogApp.tsx
@@ -13,7 +13,12 @@ import { resolveExtensionCommands, resolveExtensionSessionOptions } from "../../
import { HelpDialog } from "../components/chrome/HelpDialog";
import { MenuBar } from "../components/chrome/MenuBar";
import { MenuDropdown } from "../components/chrome/MenuDropdown";
-import type { AppMenus, MenuEntry } from "../components/chrome/menu";
+import {
+ responsiveActiveMenuSpec,
+ responsiveMenuSpecs,
+ type AppMenus,
+ type MenuEntry,
+} from "../components/chrome/menu";
import { ThemeSelectorDialog } from "../components/chrome/ThemeSelectorDialog";
import { CommitMetadataText } from "../components/CommitMetadataText";
import { RevisionIdControl } from "../components/RevisionIdControl";
@@ -572,6 +577,11 @@ export function LogApp({
1,
terminal.width - measureTextWidth(statusHint) - (statusHint ? 3 : 2),
);
+ const topTitle = `${sanitizeTerminalLine(basename(runtime.repoRoot))} · ${sanitizeTerminalLine(runtime.providerName)} history`;
+ const responsiveMenuLayout = responsiveMenuSpecs(menu.menuSpecs, terminal.width, topTitle);
+ const activeMenuSpec = menu.activeMenuId
+ ? responsiveActiveMenuSpec(responsiveMenuLayout, menu.activeMenuId)
+ : undefined;
return (
{
if (menu.activeMenuId) menu.openMenu(id);
}}
@@ -812,12 +822,12 @@ export function LogApp({
{statusHint ? {statusHint} : null}
- {menu.activeMenuId && menu.activeMenuSpec ? (
+ {menu.activeMenuId && activeMenuSpec ? (
{
});
try {
- await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
+ await session.waitForText(/View\s+Nav\s+Agent\s+\?/, {
timeout: 15_000,
});
const snapshot = await harness.waitForSnapshot(
diff --git a/test/session/broker-e2e.test.ts b/test/session/broker-e2e.test.ts
index 1ca8deacb..eef8af0f2 100644
--- a/test/session/broker-e2e.test.ts
+++ b/test/session/broker-e2e.test.ts
@@ -773,12 +773,12 @@ describe("session broker end-to-end", () => {
"rendered session after probing a conflicting broker listener",
(current) =>
conflictingRequestCount > 0 &&
- current.includes("View Navigate Agent Help") &&
+ current.includes("View Nav Agent ?") &&
current.includes(fixture.afterName) &&
current.includes("export const gamma = true;"),
);
expect(conflictingRequestCount).toBeGreaterThan(0);
- expect(transcript).toContain("View Navigate Agent Help");
+ expect(transcript).toContain("View Nav Agent ?");
expect(transcript).toContain(`${fixture.afterName}`);
expect(transcript).toContain("export const gamma = true;");
expect(await quitHunkSession(hunkProc, fixture)).toBe(0);