From 8d95da76da303debe00bc522dbf9e4ebb7b22583 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 5 Sep 2026 18:16:26 -0400 Subject: [PATCH] feat(ui): highlight active panes --- .changeset/active-pane-dividers.md | 5 + src/extensions/default/ui/index.test.ts | 2 +- .../default/ui/reviewInfo/index.test.tsx | 20 ++-- .../default/ui/reviewInfo/index.tsx | 7 +- src/ui/App.tsx | 67 +++++++---- src/ui/AppHost.key-routing.test.tsx | 12 +- src/ui/AppHost.review-metadata.test.tsx | 26 +++++ src/ui/AppHost.sidebar-resize.test.tsx | 27 ++++- src/ui/components/panes/DiffPane.tsx | 8 ++ .../components/panes/ExtensionPane.test.tsx | 4 + src/ui/components/panes/ExtensionPane.tsx | 7 +- src/ui/components/panes/PaneDivider.test.tsx | 51 ++++++++ src/ui/components/panes/PaneDivider.tsx | 25 ++-- src/ui/hooks/useActivePaneController.test.ts | 30 +++++ src/ui/hooks/useActivePaneController.ts | 110 ++++++++++++++++++ src/ui/hooks/useExtensionPaneController.ts | 6 +- src/ui/lib/extensionPanes.test.ts | 17 ++- src/ui/lib/extensionPanes.ts | 75 ++++++------ test/pty/extensions-integration.test.ts | 12 +- 19 files changed, 405 insertions(+), 106 deletions(-) create mode 100644 .changeset/active-pane-dividers.md create mode 100644 src/ui/components/panes/PaneDivider.test.tsx create mode 100644 src/ui/hooks/useActivePaneController.test.ts create mode 100644 src/ui/hooks/useActivePaneController.ts diff --git a/.changeset/active-pane-dividers.md b/.changeset/active-pane-dividers.md new file mode 100644 index 000000000..ffe2ca547 --- /dev/null +++ b/.changeset/active-pane-dividers.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Highlight active built-in and extension panes with accent-weighted separators. diff --git a/src/extensions/default/ui/index.test.ts b/src/extensions/default/ui/index.test.ts index 1d925701d..3bde0e67f 100644 --- a/src/extensions/default/ui/index.test.ts +++ b/src/extensions/default/ui/index.test.ts @@ -10,7 +10,7 @@ describe("bundled UI registry", () => { expect(reviewInfo).toMatchObject({ placement: "top", defaultOpen: true, - height: { preferred: 3, min: 3, max: 3 }, + height: { preferred: 2, min: 2, max: 2 }, }); expect( reviewInfo.available?.({ diff --git a/src/extensions/default/ui/reviewInfo/index.test.tsx b/src/extensions/default/ui/reviewInfo/index.test.tsx index 7728fb99f..4e34ada6e 100644 --- a/src/extensions/default/ui/reviewInfo/index.test.tsx +++ b/src/extensions/default/ui/reviewInfo/index.test.tsx @@ -36,7 +36,7 @@ function backgroundsAtColumn( } describe("ReviewInfoPane", () => { - test("separates review chrome with an accent rail and panel background", async () => { + test("paints review metadata with an accent rail and panel background", async () => { const theme = resolveTheme("github-dark-default", null); const width = 30; const setup = await testRender( @@ -44,11 +44,11 @@ describe("ReviewInfoPane", () => { {...({ review, width, - height: 3, + height: 2, theme, } as unknown as ExtensionPaneProps)} />, - { width, height: 3 }, + { width, height: 2 }, ); try { @@ -56,27 +56,21 @@ describe("ReviewInfoPane", () => { await setup.renderOnce(); }); expect(backgroundsAtColumn(setup, 0)).toEqual([ - theme.panel.toLowerCase(), theme.accent.toLowerCase(), theme.accent.toLowerCase(), ]); expect(backgroundsAtColumn(setup, 1)).toEqual([ theme.panel.toLowerCase(), theme.panel.toLowerCase(), - theme.panel.toLowerCase(), ]); expect(backgroundsAtColumn(setup, width - 1)).toEqual([ theme.panel.toLowerCase(), theme.panel.toLowerCase(), - theme.panel.toLowerCase(), ]); expect(backgroundsAtColumn(setup, 1)).not.toContain(theme.panelAlt.toLowerCase()); const [primary, secondary] = reviewInfoLines(review, width - 3); const frame = setup.captureCharFrame(); - expect(frame.split("\n")[0]).toBe("─".repeat(width)); - const borderSpan = setup.captureSpans().lines[0]?.spans.find((span) => span.width > 0); - expect(capturedTestColorToHex(borderSpan?.fg)).toBe(theme.border.toLowerCase()); expect(frame).toContain(` ${primary}`); expect(frame).toContain(` ${secondary}`); } finally { @@ -84,25 +78,25 @@ describe("ReviewInfoPane", () => { } }); - test("keeps the border deterministic when no metadata text fits", async () => { + test("keeps the rail deterministic when no metadata text fits", async () => { const theme = resolveTheme("github-dark-default", null); const setup = await testRender( , - { width: 1, height: 3 }, + { width: 1, height: 2 }, ); try { await act(async () => { await setup.renderOnce(); }); - expect(setup.captureCharFrame().split("\n").slice(0, 3)).toEqual(["─", " ", " "]); + expect(setup.captureCharFrame().split("\n").slice(0, 2)).toEqual([" ", " "]); } finally { setup.renderer.destroy(); } diff --git a/src/extensions/default/ui/reviewInfo/index.tsx b/src/extensions/default/ui/reviewInfo/index.tsx index 5478bc762..ea4bb0827 100644 --- a/src/extensions/default/ui/reviewInfo/index.tsx +++ b/src/extensions/default/ui/reviewInfo/index.tsx @@ -13,14 +13,11 @@ export function ReviewInfoPane({ review, theme, width }: ExtensionPaneProps): Re - - {"─".repeat(Math.max(0, width))} - { id: BUNDLED_REVIEW_INFO_VIEW_ID, title: "Review info", placement: "top", - height: { preferred: 3, min: 3, max: 3 }, + height: { preferred: 2, min: 2, max: 2 }, defaultOpen: true, available: ({ review }) => review?.kind === "change-request", component: ReviewInfoPane, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 6ff7452df..0db0edf89 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -3,6 +3,7 @@ import type { MouseEvent as TuiMouseEvent, ScrollBoxRenderable, } from "@opentui/core"; +import { MouseButton } from "@opentui/core"; import { useRenderer, useTerminalDimensions } from "@opentui/react"; import { Suspense, @@ -108,6 +109,7 @@ import { setMouseCapture } from "./lib/mouseCapture"; import { openSelectedFileInEditor } from "./lib/openInEditor"; import { resolveResponsiveLayout } from "./lib/responsive"; import type { WorkspaceRefreshRequest } from "./currentReviewRefresh"; +import { useActivePaneController } from "./hooks/useActivePaneController"; type FocusArea = "files" | "filter" | "note"; @@ -480,6 +482,12 @@ export function App({ pagerMode, responsiveShowsSidebar: responsiveLayout.showSidebar, }); + const visiblePaneKeys = useMemo( + () => paneLayout.panes.map(({ pane }) => pane.key), + [paneLayout.panes], + ); + const { activePaneKey, activatePane, activateReview, paneSurfaceRef, reviewSurfaceRef } = + useActivePaneController({ renderer, visiblePaneKeys }); useEffect(() => { if (resizingPaneKey === null) { @@ -1199,6 +1207,7 @@ export function App({ return ( reportPaneRenderFailure(pane) } @@ -1246,37 +1256,39 @@ export function App({ // so a fast motion or a sidebar projection swap cannot transfer the gesture to a transient row. const beginCapturedPaneResize = (planned: PlannedPane, event: TuiMouseEvent) => { if (!beginPaneResize(planned, event)) return; + activatePane(planned.pane.key); if (paneResizeCaptureRef.current) { setMouseCapture(renderer, paneResizeCaptureRef.current); } closeMenu(); }; - const renderDivider = (planned: PlannedPane) => - planned.divider ? ( - - beginCapturedPaneResize(planned, event)} - onMouseDrag={updatePaneResize} - onMouseDragEnd={endPaneResize} - onMouseUp={endPaneResize} - /> - - ) : null; + const renderDivider = (planned: PlannedPane) => ( + + beginCapturedPaneResize(planned, event)} + onMouseDrag={updatePaneResize} + onMouseDragEnd={endPaneResize} + onMouseUp={endPaneResize} + /> + + ); return ( { + if (event.button === MouseButton.LEFT) activateReview(); + }} > { join(extension, "index.tsx"), `import { createElement, useState } from "react"; export default function (hunk) { + let showPrompt = () => {}; hunk.registerPane({ id: "prompt", placement: "bottom", defaultOpen: true, height: { preferred: 3, min: 3, max: 3 }, component: () => { + const [visible, setVisible] = useState(false); const [value, setValue] = useState(""); - return createElement("input", { value, focused: true, onInput: setValue }); + showPrompt = () => setVisible(true); + return visible + ? createElement("input", { value, focused: true, onInput: setValue }) + : createElement("text", { content: "PROMPT CLOSED" }); }, }); + hunk.registerCommand({ id: "focus", title: "Focus prompt", key: "o" }, () => showPrompt()); hunk.registerCommand({ id: "letter", title: "Letter command", key: "j" }, (ctx) => { ctx.notify("COMMAND FIRED"); }); @@ -325,6 +331,9 @@ export default function (hunk) { }); try { + await waitForFrame(setup, () => setup.captureCharFrame().includes("PROMPT CLOSED"), 12); + expect(setup.renderer.currentFocusedEditor).toBeNull(); + await act(async () => setup.mockInput.typeText("o")); await waitForFrame(setup, () => setup.renderer.currentFocusedEditor !== null, 12); expect(setup.renderer.currentFocusedEditor).not.toBeNull(); @@ -333,6 +342,7 @@ export default function (hunk) { const frame = setup.captureCharFrame(); expect(frame).toContain("j?"); + expect(frame).toContain("━"); expect(frame).not.toContain("COMMAND FIRED"); expect(frame).not.toContain("Controls help"); } finally { diff --git a/src/ui/AppHost.review-metadata.test.tsx b/src/ui/AppHost.review-metadata.test.tsx index 74d4aa194..1d2bfcf90 100644 --- a/src/ui/AppHost.review-metadata.test.tsx +++ b/src/ui/AppHost.review-metadata.test.tsx @@ -108,6 +108,32 @@ async function flushUntil( } describe("delegated review metadata reloads", () => { + test("gives the fixed built-in review pane an active host separator", async () => { + const fixture = await createTestBootstrap(); + const setup = await testRender(, { + width: 100, + height: 12, + }); + try { + await flushUntil( + setup, + () => setup.captureCharFrame().includes("OPEN · #123 · Metadata pane"), + "the delegated review pane to mount", + ); + expect(setup.captureCharFrame().split("\n")[3]).toContain("─"); + + await act(async () => setup.mockMouse.click(50, 1)); + await flushUntil( + setup, + () => setup.captureCharFrame().split("\n")[3]?.includes("━") === true, + "the fixed pane separator to activate", + ); + } finally { + await act(async () => setup.renderer.destroy()); + rmSync(fixture.directory, { recursive: true, force: true }); + } + }); + test("the bundled review pane occupies exactly three rows only for delegated change requests", async () => { const delegated = await createTestBootstrap(); const ordinary = await createTestBootstrap(); diff --git a/src/ui/AppHost.sidebar-resize.test.tsx b/src/ui/AppHost.sidebar-resize.test.tsx index f0e905e10..78692d057 100644 --- a/src/ui/AppHost.sidebar-resize.test.tsx +++ b/src/ui/AppHost.sidebar-resize.test.tsx @@ -69,7 +69,17 @@ async function flush(setup: Awaited>) { /** Column of the vertical sidebar/diff divider on the probe row, or -1 when absent. */ function dividerColumn(setup: Awaited>) { const row = setup.captureCharFrame().split("\n")[PROBE_ROW] ?? ""; - return row.indexOf("│"); + const normal = row.indexOf("│"); + const active = row.indexOf("┃"); + if (normal < 0) return active; + if (active < 0) return normal; + return Math.min(normal, active); +} + +/** Read the sidebar divider's normal or emphasized vertical glyph. */ +function dividerGlyph(setup: Awaited>) { + const row = setup.captureCharFrame().split("\n")[PROBE_ROW] ?? ""; + return row[dividerColumn(setup)] ?? ""; } /** Return only the file-sidebar columns so diff headers cannot satisfy sidebar assertions. */ @@ -139,6 +149,20 @@ afterEach(() => { }); describe("AppHost sidebar resize", () => { + test("moves active emphasis between the built-in files pane and review", async () => { + setup = await testRender(, WIDE); + await flush(setup); + expect(dividerGlyph(setup)).toBe("│"); + + await act(async () => setup!.mockMouse.click(8, PROBE_ROW)); + await flush(setup); + expect(dividerGlyph(setup)).toBe("┃"); + + await act(async () => setup!.mockMouse.click(INITIAL_DIVIDER_COLUMN + 8, PROBE_ROW)); + await flush(setup); + expect(dividerGlyph(setup)).toBe("│"); + }); + test("resizes the default sidebar with the terminal until the user drags it", async () => { setup = await testRender(, WIDE); await flush(setup); @@ -166,6 +190,7 @@ describe("AppHost sidebar resize", () => { // The divider follows the new width: startWidth + (currentX - originX). expect(dividerColumn(setup)).toBeGreaterThan(INITIAL_DIVIDER_COLUMN); + expect(dividerGlyph(setup)).toBe("┃"); }); test("resizing across the content-width threshold switches the file projection", async () => { diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index fe8b6f44c..43dfdaa7d 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -352,6 +352,7 @@ export function DiffPane({ onFocusDraftNote, onCopyFeedback, onCopySelectionText, + onActivateSurface, onFileViewRowFailure, onScrollCodeHorizontally = () => {}, onSelectFile, @@ -423,6 +424,7 @@ export function DiffPane({ onFocusDraftNote?: () => void; onCopyFeedback?: (text: string) => void; onCopySelectionText?: (text: string) => void | boolean; + onActivateSurface?: () => void; onFileViewRowFailure?: (failure: FileViewRowFailure) => void; onScrollCodeHorizontally?: (delta: number) => void; onSelectFile: (fileId: string) => void; @@ -449,6 +451,8 @@ export function DiffPane({ const hoveredFileIdRef = useRef(null); const onActiveAddNoteAffordanceChangeRef = useRef(onActiveAddNoteAffordanceChange); onActiveAddNoteAffordanceChangeRef.current = onActiveAddNoteAffordanceChange; + const onActivateSurfaceRef = useRef(onActivateSurface); + onActivateSurfaceRef.current = onActivateSurface; /** Hide hover-only row controls when content scrolls under a stationary mouse pointer. */ const clearAddNoteHoverForScroll = useCallback(() => { @@ -1512,6 +1516,7 @@ export function DiffPane({ if (event.button !== MouseButton.LEFT) { return; } + onActivateSurfaceRef.current?.(); const point = resolveCopySelectionPoint(event); if (!point) { @@ -2679,6 +2684,9 @@ export function DiffPane({ ? { paddingY: 1 } : { paddingTop: 0, paddingBottom: pagerMode ? 0 : 1 }), }} + onMouseDown={(event) => { + if (event.button === MouseButton.LEFT) onActivateSurfaceRef.current?.(); + }} onMouseDragEnd={endCopySelection} onMouseUp={endCopySelection} > diff --git a/src/ui/components/panes/ExtensionPane.test.tsx b/src/ui/components/panes/ExtensionPane.test.tsx index 3afe6cbc6..941883a5d 100644 --- a/src/ui/components/panes/ExtensionPane.test.tsx +++ b/src/ui/components/panes/ExtensionPane.test.tsx @@ -168,6 +168,7 @@ describe("ExtensionPaneHost activation", () => { const files = createTestFiles(); const theme = resolveTheme("github-dark-default", null); let activations = 0; + const surfaceActivations: string[] = []; let childPresses = 0; const registered = registeredView(() => ( @@ -203,14 +204,17 @@ describe("ExtensionPaneHost activation", () => { onSelectFile={() => {}} onSelectHunk={() => {}} onRevealLine={() => "line"} + onActivateSurface={(key) => surfaceActivations.push(key)} />, async (setup) => { await act(async () => setup.mockMouse.click(2, 0, MouseButtons.LEFT)); expect(activations).toBe(1); + expect(surfaceActivations).toEqual(["probe:probe-view"]); expect(childPresses).toBe(1); await act(async () => setup.mockMouse.click(2, 0, MouseButtons.RIGHT)); expect(activations).toBe(1); + expect(surfaceActivations).toEqual(["probe:probe-view"]); expect(childPresses).toBe(2); }, ); diff --git a/src/ui/components/panes/ExtensionPane.tsx b/src/ui/components/panes/ExtensionPane.tsx index 651996dc8..17ab05f5a 100644 --- a/src/ui/components/panes/ExtensionPane.tsx +++ b/src/ui/components/panes/ExtensionPane.tsx @@ -93,6 +93,7 @@ export interface ExtensionPaneHostProps { onSelectFile: (fileId: string) => void; onSelectHunk: (fileId: string, hunkIndex: number) => void; onRevealLine: (fileId: string, side: "old" | "new", line: number) => "line" | "hunk" | "none"; + onActivateSurface?: (paneKey: string) => void; onRenderFailure?: () => void; } @@ -115,6 +116,7 @@ function ExtensionPaneHostView({ onSelectFile, onSelectHunk, onRevealLine, + onActivateSurface, onRenderFailure, }: ExtensionPaneHostProps) { const { extensionId } = registered; @@ -159,7 +161,9 @@ function ExtensionPaneHostView({ }; const filesChrome = paneKey(registered) === HUNK_FILES_PANE_KEY; const onMouseDown = (event: TuiMouseEvent) => { - if (event.button === MouseButton.LEFT) activatePane(registered, notify); + if (event.button !== MouseButton.LEFT) return; + onActivateSurface?.(paneKey(registered)); + activatePane(registered, notify); }; const box = (children: ReactNode) => ( {}; + +/** Render one divider and capture both its cells and paint spans. */ +async function captureDivider(isActive: boolean) { + const setup = await testRender( + , + { width: 8, height: 1 }, + ); + try { + await act(async () => setup.renderOnce()); + return { frame: setup.captureCharFrame(), spans: setup.captureSpans() }; + } finally { + await act(async () => setup.renderer.destroy()); + } +} + +describe("PaneDivider", () => { + test("changes weight and semantic color when its pane is active", async () => { + const inactive = await captureDivider(false); + const active = await captureDivider(true); + const inactiveSpan = inactive.spans.lines.flatMap((line) => line.spans)[0]; + const activeSpan = active.spans.lines.flatMap((line) => line.spans)[0]; + + expect(inactive.frame).toContain("────────"); + expect(active.frame).toContain("━━━━━━━━"); + expect(capturedTestColorToHex(inactiveSpan?.fg)?.toLowerCase()).toBe( + theme.border.toLowerCase(), + ); + expect(capturedTestColorToHex(activeSpan?.fg)?.toLowerCase()).toBe(theme.accent.toLowerCase()); + }); +}); diff --git a/src/ui/components/panes/PaneDivider.tsx b/src/ui/components/panes/PaneDivider.tsx index e1f3c69df..7b4c8bd40 100644 --- a/src/ui/components/panes/PaneDivider.tsx +++ b/src/ui/components/panes/PaneDivider.tsx @@ -4,12 +4,14 @@ import type { AppTheme } from "../../themes"; const PANE_DIVIDER_HIT_AREA_SIZE = 5; const PANE_DIVIDER_HIT_AREA_OFFSET = Math.floor(PANE_DIVIDER_HIT_AREA_SIZE / 2); -/** Render a one-cell pane divider with a larger pointer target on either axis. */ +/** Render a one-cell pane separator, adding a larger pointer target when it is resizable. */ export function PaneDivider({ orientation, width, height, + isActive, isResizing, + resizable, theme, onMouseDown, onMouseDrag, @@ -19,13 +21,18 @@ export function PaneDivider({ orientation: "vertical" | "horizontal"; width: number; height: number; + isActive: boolean; isResizing: boolean; + resizable: boolean; theme: AppTheme; onMouseDown: (event: TuiMouseEvent) => void; onMouseDrag: (event: TuiMouseEvent) => void; onMouseDragEnd: (event: TuiMouseEvent) => void; onMouseUp: (event: TuiMouseEvent) => void; }) { + const emphasized = isActive || isResizing; + const horizontal = emphasized ? "━" : "─"; + const vertical = emphasized ? "┃" : "│"; const handlers = { onMouseDown, onMouseDrag, onMouseUp, onMouseDragEnd }; const hitAreaStyle = orientation === "vertical" @@ -54,15 +61,15 @@ export function PaneDivider({ flexShrink: 0, backgroundColor: isResizing ? theme.accentMuted : theme.panel, border: orientation === "vertical" ? ["left"] : ["top"], - borderColor: isResizing ? theme.accent : theme.border, + borderColor: emphasized ? theme.accent : theme.border, }} customBorderChars={{ - topLeft: orientation === "vertical" ? "│" : "─", - topRight: "─", - bottomLeft: "│", - bottomRight: "─", - horizontal: "─", - vertical: "│", + topLeft: orientation === "vertical" ? vertical : horizontal, + topRight: horizontal, + bottomLeft: vertical, + bottomRight: horizontal, + horizontal, + vertical, topT: "┬", bottomT: "┴", leftT: "├", @@ -70,7 +77,7 @@ export function PaneDivider({ cross: "┼", }} /> - + {resizable ? : null} ); } diff --git a/src/ui/hooks/useActivePaneController.test.ts b/src/ui/hooks/useActivePaneController.test.ts new file mode 100644 index 000000000..dc10d4e31 --- /dev/null +++ b/src/ui/hooks/useActivePaneController.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import type { Renderable } from "@opentui/core"; +import { paneKeyFromFocusedRenderable } from "./useActivePaneController"; + +/** Build the minimal renderable ancestry needed to test host surface ownership. */ +function renderable(id: string, parent: Renderable | null = null): Renderable { + return { id, parent } as Renderable; +} + +describe("active pane focus ownership", () => { + test("finds pane and review roots through focused descendants", () => { + const pane = renderable("pane"); + const editor = renderable("prompt", renderable("card", pane)); + const review = renderable("review"); + const noteEditor = renderable("draft", review); + const roots = new Map([ + [pane, "fixture:agent"], + [review, null], + ]); + + expect(paneKeyFromFocusedRenderable(editor, roots)).toBe("fixture:agent"); + expect(paneKeyFromFocusedRenderable(noteEditor, roots)).toBeNull(); + }); + + test("ignores focus outside the review workspace", () => { + const roots = new Map(); + expect(paneKeyFromFocusedRenderable(renderable("menu"), roots)).toBeUndefined(); + expect(paneKeyFromFocusedRenderable(null, roots)).toBeUndefined(); + }); +}); diff --git a/src/ui/hooks/useActivePaneController.ts b/src/ui/hooks/useActivePaneController.ts new file mode 100644 index 000000000..40bd16b04 --- /dev/null +++ b/src/ui/hooks/useActivePaneController.ts @@ -0,0 +1,110 @@ +/** + * Tracks the review or docked pane the user most recently engaged. + * + * Pointer activation and focused descendants converge here while modal focus leaves the underlying + * workspace surface intact. Panes that leave the committed layout fall back to the review. + */ + +import type { BoxRenderable, CliRenderer, Renderable } from "@opentui/core"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; + +/** Resolve a focused renderable to its containing workspace surface, when it has one. */ +export function paneKeyFromFocusedRenderable( + renderable: Renderable | null, + surfaceRoots: ReadonlyMap, +): string | null | undefined { + let current = renderable; + while (current) { + if (surfaceRoots.has(current)) return surfaceRoots.get(current); + current = current.parent; + } + return undefined; +} + +/** Own logical workspace activation independently from concrete OpenTUI editor focus. */ +export function useActivePaneController({ + renderer, + visiblePaneKeys, +}: { + renderer: CliRenderer; + visiblePaneKeys: readonly string[]; +}) { + const [activePaneKey, setActivePaneKey] = useState(null); + const surfaceRootsRef = useRef(new Map()); + const paneRootsRef = useRef(new Map()); + const paneRootCallbacksRef = useRef( + new Map void>(), + ); + const reviewRootRef = useRef(null); + const visiblePaneKeysRef = useRef(visiblePaneKeys); + + const paneSurfaceRef = useCallback((paneKey: string) => { + let callback = paneRootCallbacksRef.current.get(paneKey); + if (!callback) { + callback = (renderable) => { + const previous = paneRootsRef.current.get(paneKey); + if (previous) surfaceRootsRef.current.delete(previous); + if (renderable) { + paneRootsRef.current.set(paneKey, renderable); + surfaceRootsRef.current.set(renderable, paneKey); + } else { + paneRootsRef.current.delete(paneKey); + } + }; + paneRootCallbacksRef.current.set(paneKey, callback); + } + return callback; + }, []); + + const reviewSurfaceRef = useCallback((renderable: BoxRenderable | null) => { + if (reviewRootRef.current) surfaceRootsRef.current.delete(reviewRootRef.current); + reviewRootRef.current = renderable; + if (renderable) surfaceRootsRef.current.set(renderable, null); + }, []); + + useEffect(() => { + let live = true; + const activateFocusedSurface = (current: Renderable | null) => { + const paneKey = paneKeyFromFocusedRenderable(current, surfaceRootsRef.current); + if ( + paneKey === undefined || + (paneKey !== null && !visiblePaneKeysRef.current.includes(paneKey)) + ) { + return false; + } + setActivePaneKey(paneKey); + return true; + }; + const syncFocusedSurface = (current: Renderable | null) => { + if (activateFocusedSurface(current) || !current) return; + // Newly focused renderables can emit before React attaches their host-pane ancestry. + queueMicrotask(() => { + if (live && renderer.currentFocusedRenderable === current) activateFocusedSurface(current); + }); + }; + renderer.on("focused_renderable", syncFocusedSurface); + syncFocusedSurface(renderer.currentFocusedRenderable); + return () => { + live = false; + renderer.off("focused_renderable", syncFocusedSurface); + }; + }, [renderer]); + + useLayoutEffect(() => { + visiblePaneKeysRef.current = visiblePaneKeys; + setActivePaneKey((current) => + current !== null && !visiblePaneKeys.includes(current) ? null : current, + ); + }, [visiblePaneKeys]); + + const activatePane = useCallback((paneKey: string) => setActivePaneKey(paneKey), []); + const activateReview = useCallback(() => setActivePaneKey(null), []); + + return { + activePaneKey, + activatePane, + activateReview, + paneSurfaceRef, + reviewSurfaceRef, + }; +} diff --git a/src/ui/hooks/useExtensionPaneController.ts b/src/ui/hooks/useExtensionPaneController.ts index 2692202f8..08e224278 100644 --- a/src/ui/hooks/useExtensionPaneController.ts +++ b/src/ui/hooks/useExtensionPaneController.ts @@ -115,7 +115,7 @@ function activeResizePane( planned.pane.key === resize.key && planned.pane.registered === resize.registered && planned.pane.placement === resize.placement && - planned.divider !== undefined, + planned.resizable, ); } @@ -524,13 +524,13 @@ export function useExtensionPaneController({ // Start a drag only for the divider still owned by this exact pane registration. const beginPaneResize = useCallback( (planned: PlannedPane, event: TuiMouseEvent): boolean => { - if (event.button !== MouseButton.LEFT || !planned.divider) return false; + if (event.button !== MouseButton.LEFT || !planned.resizable) return false; const committed = paneLayoutRef.current?.panes.find( (entry) => entry.pane.key === planned.pane.key && entry.pane.registered === planned.pane.registered && entry.pane.placement === planned.pane.placement && - entry.divider !== undefined, + entry.resizable, ); if (!committed) return false; const vertical = committed.pane.placement === "left" || committed.pane.placement === "right"; diff --git a/src/ui/lib/extensionPanes.test.ts b/src/ui/lib/extensionPanes.test.ts index 72ff1de7c..53bad97a5 100644 --- a/src/ui/lib/extensionPanes.test.ts +++ b/src/ui/lib/extensionPanes.test.ts @@ -168,8 +168,9 @@ describe("extension panes", () => { const files = layout.panes.find((pane) => pane.pane.key === HUNK_FILES_PANE_KEY)!; const info = layout.panes.find((pane) => pane.pane.key === "hunk:review-info")!; expect(files.bounds).toEqual({ x: 0, y: 0, width: 38, height: 30 }); - expect(info.bounds).toEqual({ x: 39, y: 0, width: 201, height: 3 }); - expect(info.divider).toBeUndefined(); + expect(info.bounds).toEqual({ x: 39, y: 0, width: 201, height: 2 }); + expect(info.divider).toEqual({ x: 39, y: 2, width: 201, height: 1 }); + expect(info.resizable).toBeFalse(); expect(layout.reviewBounds).toEqual({ x: 39, y: 3, width: 201, height: 27 }); }); @@ -205,13 +206,20 @@ describe("extension panes", () => { minReviewWidth: 40, minReviewHeight: 5, }); - expect(plan.reviewBounds).toEqual({ x: 20, y: 4, width: 65, height: 23 }); + expect(plan.reviewBounds).toEqual({ x: 21, y: 5, width: 63, height: 21 }); expect(plan.panes.map((entry) => entry.pane.placement)).toEqual([ "left", "right", "top", "bottom", ]); + expect(plan.panes.map((entry) => entry.resizable)).toEqual([false, false, false, false]); + expect(plan.panes.map((entry) => entry.divider)).toEqual([ + { x: 20, y: 0, width: 1, height: 30 }, + { x: 84, y: 0, width: 1, height: 30 }, + { x: 21, y: 4, width: 63, height: 1 }, + { x: 21, y: 26, width: 63, height: 1 }, + ]); }); test("separates commit-phase availability from pure geometry planning", () => { @@ -474,7 +482,7 @@ describe("extension panes", () => { expect(heights(60)).toEqual([15, 15]); }); - test("uses explicit height overrides and reserves a divider only for resizable panes", () => { + test("uses explicit height overrides and marks resizable pane dividers", () => { const registered = registeredPane("a", "top", { placement: "top", height: { preferred: 4, min: 2, max: 8 }, @@ -493,6 +501,7 @@ describe("extension panes", () => { const top = plan.panes.find((entry) => entry.pane.key === "a:top"); expect(top?.bounds).toEqual({ x: 0, y: 0, width: 100, height: 7 }); expect(top?.divider).toEqual({ x: 0, y: 7, width: 100, height: 1 }); + expect(top?.resizable).toBeTrue(); expect(plan.reviewBounds).toEqual({ x: 0, y: 8, width: 100, height: 12 }); }); diff --git a/src/ui/lib/extensionPanes.ts b/src/ui/lib/extensionPanes.ts index ca556e0c2..bebbf6436 100644 --- a/src/ui/lib/extensionPanes.ts +++ b/src/ui/lib/extensionPanes.ts @@ -9,7 +9,7 @@ import type { import { extensionPaneSize } from "../../extensions/panes"; import type { ExtensionLoadResult, RegisteredPane } from "../../extensions/types"; -/** One cell reserved between each resizable pane and its neighbor. */ +/** One cell reserved between each pane and its neighbor. */ export const EXTENSION_PANE_DIVIDER_SIZE = 1; /** Smallest review height preserved while edge panes are open or resized. */ export const MIN_EXTENSION_REVIEW_HEIGHT = 5; @@ -134,7 +134,8 @@ export interface PaneBounds { export interface PlannedPane { pane: SessionPane; bounds: PaneBounds; - divider?: PaneBounds; + divider: PaneBounds; + resizable: boolean; } export interface ExtensionPaneLayoutPlan { panes: readonly PlannedPane[]; @@ -232,7 +233,7 @@ export function planExtensionPanes(options: PlanExtensionPanesOptions): Extensio target: options.sizes[pane.key] ?? automaticSize, min, max, - fixed: min === max, + resizable: min !== max, }; }; @@ -240,7 +241,7 @@ export function planExtensionPanes(options: PlanExtensionPanesOptions): Extensio (pane) => pane.placement === "left" || pane.placement === "right", )) { const spec = sizeSpec(pane); - const dividerSize = spec.fixed ? 0 : EXTENSION_PANE_DIVIDER_SIZE; + const dividerSize = EXTENSION_PANE_DIVIDER_SIZE; const remaining = right - left - options.minReviewWidth - dividerSize; const width = Math.min(Math.max(spec.target, spec.min), spec.max, remaining); if (width < spec.min) { @@ -249,27 +250,23 @@ export function planExtensionPanes(options: PlanExtensionPanesOptions): Extensio } if (pane.placement === "left") { const bounds = { x: left, y: 0, width, height: options.bodyHeight }; - const divider = dividerSize - ? { - x: left + width, - y: 0, - width: EXTENSION_PANE_DIVIDER_SIZE, - height: options.bodyHeight, - } - : undefined; - planned.set(pane.key, { pane, bounds, ...(divider ? { divider } : {}) }); + const divider = { + x: left + width, + y: 0, + width: EXTENSION_PANE_DIVIDER_SIZE, + height: options.bodyHeight, + }; + planned.set(pane.key, { pane, bounds, divider, resizable: spec.resizable }); left += width + dividerSize; } else { const bounds = { x: right - width, y: 0, width, height: options.bodyHeight }; - const divider = dividerSize - ? { - x: right - width - EXTENSION_PANE_DIVIDER_SIZE, - y: 0, - width: EXTENSION_PANE_DIVIDER_SIZE, - height: options.bodyHeight, - } - : undefined; - planned.set(pane.key, { pane, bounds, ...(divider ? { divider } : {}) }); + const divider = { + x: right - width - EXTENSION_PANE_DIVIDER_SIZE, + y: 0, + width: EXTENSION_PANE_DIVIDER_SIZE, + height: options.bodyHeight, + }; + planned.set(pane.key, { pane, bounds, divider, resizable: spec.resizable }); right -= width + dividerSize; } } @@ -278,7 +275,7 @@ export function planExtensionPanes(options: PlanExtensionPanesOptions): Extensio (pane) => pane.placement === "top" || pane.placement === "bottom", )) { const spec = sizeSpec(pane); - const dividerSize = spec.fixed ? 0 : EXTENSION_PANE_DIVIDER_SIZE; + const dividerSize = EXTENSION_PANE_DIVIDER_SIZE; const remaining = bottom - top - options.minReviewHeight - dividerSize; const height = Math.min(Math.max(spec.target, spec.min), spec.max, remaining); if (height < spec.min) { @@ -287,27 +284,23 @@ export function planExtensionPanes(options: PlanExtensionPanesOptions): Extensio } if (pane.placement === "top") { const bounds = { x: left, y: top, width: right - left, height }; - const divider = dividerSize - ? { - x: left, - y: top + height, - width: right - left, - height: EXTENSION_PANE_DIVIDER_SIZE, - } - : undefined; - planned.set(pane.key, { pane, bounds, ...(divider ? { divider } : {}) }); + const divider = { + x: left, + y: top + height, + width: right - left, + height: EXTENSION_PANE_DIVIDER_SIZE, + }; + planned.set(pane.key, { pane, bounds, divider, resizable: spec.resizable }); top += height + dividerSize; } else { const bounds = { x: left, y: bottom - height, width: right - left, height }; - const divider = dividerSize - ? { - x: left, - y: bottom - height - EXTENSION_PANE_DIVIDER_SIZE, - width: right - left, - height: EXTENSION_PANE_DIVIDER_SIZE, - } - : undefined; - planned.set(pane.key, { pane, bounds, ...(divider ? { divider } : {}) }); + const divider = { + x: left, + y: bottom - height - EXTENSION_PANE_DIVIDER_SIZE, + width: right - left, + height: EXTENSION_PANE_DIVIDER_SIZE, + }; + planned.set(pane.key, { pane, bounds, divider, resizable: spec.resizable }); bottom -= height + dividerSize; } } diff --git a/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index 8ba5bac2c..cb82abf95 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -384,7 +384,7 @@ describe("PTY extensions", () => { ); expect(frame).toContain("octocat · GitHub · modem-dev/hunk · main ← feature/pane"); const infoLine = lineIndexOf(frame, "OPEN · #123"); - expect(frame.split("\n")[infoLine - 1]).toContain("─"); + expect(frame.split("\n")[infoLine + 2]).toContain("─"); expect(infoLine).toBeLessThan(lineIndexOf(frame, "after")); } finally { session.close(); @@ -667,10 +667,18 @@ describe("PTY extensions", () => { try { const frame = await session.waitForText(/PANE ACTIVATE TARGET/, { timeout: 20_000 }); const targetRow = lineIndexOf(frame, "PANE ACTIVATE TARGET"); - const targetColumn = frame.split("\n")[targetRow]!.indexOf("PANE ACTIVATE TARGET") + 5; + const targetStart = frame.split("\n")[targetRow]!.indexOf("PANE ACTIVATE TARGET"); + const targetColumn = targetStart + 5; + expect(frame.split("\n")[targetRow]![targetStart - 1]).toBe("│"); // Stay clear of the pane divider's intentional multi-cell resize hit area. await session.clickAt(targetColumn, targetRow); + await harness.waitForSnapshot( + session, + (text) => text.split("\n")[targetRow]?.[targetStart - 1] === "┃", + 5_000, + ); + const deadline = Date.now() + 5_000; while (!existsSync(activationLog) && Date.now() < deadline) { await Bun.sleep(20);