Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -508,8 +508,6 @@ function AppShell({
0,
);
const topTitle = `${bootstrap.changeset.title} +${totalAdditions} -${totalDeletions}`;
const helpWidth = Math.min(68, Math.max(44, terminal.width - 8));
const helpLeft = Math.max(1, Math.floor((terminal.width - helpWidth) / 2));
const filesTextWidth = Math.max(8, clampedFilesPaneWidth - 4);
const diffContentWidth = Math.max(12, diffPaneWidth - 2);
const diffHeaderStatsWidth = Math.min(24, Math.max(16, Math.floor(diffContentWidth / 3)));
Expand Down Expand Up @@ -856,10 +854,8 @@ function AppShell({
/>
</box>

{!pagerMode ? (
{!pagerMode && (focusArea === "filter" || Boolean(filter)) ? (
<StatusBar
canRefresh={canRefreshCurrentInput}
canResizeDivider={showFilesPane}
filter={filter}
filterFocused={focusArea === "filter"}
terminalWidth={terminal.width}
Expand Down Expand Up @@ -892,9 +888,9 @@ function AppShell({
<Suspense fallback={null}>
<LazyHelpDialog
canRefresh={canRefreshCurrentInput}
left={helpLeft}
terminalHeight={terminal.height}
terminalWidth={terminal.width}
theme={activeTheme}
width={helpWidth}
onClose={() => setShowHelp(false)}
/>
</Suspense>
Expand Down
98 changes: 66 additions & 32 deletions src/ui/components/chrome/HelpDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,81 @@
import { fitText, padText } from "../../lib/text";
import type { AppTheme } from "../../themes";
import { ModalFrame } from "./ModalFrame";

/** Render the compact keyboard help overlay. */
/** Render the keyboard help modal. */
export function HelpDialog({
canRefresh = false,
left,
terminalHeight,
terminalWidth,
theme,
width,
onClose,
}: {
canRefresh?: boolean;
left: number;
terminalHeight: number;
terminalWidth: number;
theme: AppTheme;
width: number;
onClose: () => void;
}) {
const sections = [
{
title: "Navigation",
items: [
["↑ / ↓", "move line-by-line"],
["Space / b", "page down / page up"],
["[ / ]", "previous / next hunk"],
["Home / End", "jump to top / bottom"],
],
},
{
title: "View",
items: [
["1 / 2 / 0", "split / stack / auto"],
["s / t", "sidebar / theme"],
["a", "toggle AI notes"],
["l / w / m", "lines / wrap / metadata"],
],
},
{
title: "Review",
items: [
["/", "focus file filter"],
["Tab", "swap files / filter focus"],
["F10", "open menus"],
[canRefresh ? "r / q" : "q", canRefresh ? "reload / quit" : "quit"],
],
},
] as const;

const width = Math.min(74, Math.max(56, terminalWidth - 8));
const bodyWidth = Math.max(1, width - 4);
const keyWidth = Math.min(16, Math.max(12, Math.floor(bodyWidth * 0.28)));
const descriptionWidth = Math.max(1, bodyWidth - keyWidth);
const height = Math.min(
sections.reduce((total, section) => total + 1 + section.items.length, 0) + 3,
Math.max(8, terminalHeight - 2),
);

return (
<box
style={{
position: "absolute",
top: 3,
left,
width,
height: canRefresh ? 10 : 9,
zIndex: 60,
border: true,
borderColor: theme.accent,
backgroundColor: theme.panel,
padding: 1,
flexDirection: "column",
gap: 1,
}}
onMouseUp={onClose}
<ModalFrame
height={height}
terminalHeight={terminalHeight}
terminalWidth={terminalWidth}
theme={theme}
title="Keyboard help"
width={width}
onClose={onClose}
>
<text fg={theme.text}>Keyboard</text>
<text fg={theme.muted}>F10 menus arrows navigate menus Enter select Esc close menu</text>
<text fg={theme.muted}>1 split 2 stack 0 auto t theme a notes l lines w wrap m meta</text>
{canRefresh ? <text fg={theme.muted}>r reload the current diff</text> : null}
<text fg={theme.muted}>
↑/↓ line scroll space next page b previous page Home/End jump [ previous hunk ] next hunk
</text>
<text fg={theme.muted}>drag the Files/Diff divider with the mouse to resize the columns</text>
<text fg={theme.muted}>/ focus filter Tab swap files/filter q quit</text>
<text fg={theme.badgeNeutral}>click anywhere on this panel to close</text>
</box>
{sections.map((section) => (
<box key={section.title} style={{ flexDirection: "column" }}>
<text fg={theme.badgeNeutral}>{section.title}</text>
{section.items.map(([keys, description]) => (
<box key={`${section.title}:${keys}`} style={{ flexDirection: "row" }}>
<text fg={theme.accent}>{padText(fitText(keys, keyWidth), keyWidth)}</text>
<text fg={theme.muted}>{fitText(description, descriptionWidth)}</text>
</box>
))}
</box>
))}
</ModalFrame>
);
}
95 changes: 95 additions & 0 deletions src/ui/components/chrome/ModalFrame.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { MouseEvent as TuiMouseEvent } from "@opentui/core";
import type { ReactNode } from "react";
import { fitText, padText } from "../../lib/text";
import type { AppTheme } from "../../themes";

/** Render a centered framed modal shell that other dialogs can reuse. */
export function ModalFrame({
children,
height,
onClose,
terminalHeight,
terminalWidth,
theme,
title,
width,
}: {
children: ReactNode;
height: number;
onClose?: () => void;
terminalHeight: number;
terminalWidth: number;
theme: AppTheme;
title: string;
width: number;
}) {
const clampedWidth = Math.min(width, Math.max(24, terminalWidth - 2));
const clampedHeight = Math.min(height, Math.max(5, terminalHeight - 2));
const left = Math.max(1, Math.floor((terminalWidth - clampedWidth) / 2));
const top = Math.max(1, Math.floor((terminalHeight - clampedHeight) / 2));
const closeText = onClose ? "[Esc]" : "";
const titleWidth = Math.max(1, clampedWidth - 2 - (closeText ? closeText.length + 1 : 0));

return (
<>
<box
style={{
position: "absolute",
top: 0,
left: 0,
width: terminalWidth,
height: terminalHeight,
zIndex: 55,
}}
onMouseUp={onClose}
/>
<box
style={{
position: "absolute",
top,
left,
width: clampedWidth,
height: clampedHeight,
zIndex: 60,
border: true,
borderColor: theme.accent,
backgroundColor: theme.panel,
flexDirection: "column",
}}
onMouseUp={(event: TuiMouseEvent) => event.stopPropagation()}
>
<box
style={{
paddingLeft: 1,
paddingRight: 1,
paddingTop: 1,
flexDirection: "row",
}}
>
<text fg={theme.text}>{padText(fitText(title, titleWidth), titleWidth)}</text>
{closeText ? (
<box
onMouseUp={(event: TuiMouseEvent) => {
event.stopPropagation();
onClose?.();
}}
>
<text fg={theme.badgeNeutral}>{closeText}</text>
</box>
) : null}
</box>
<box
style={{
paddingLeft: 1,
paddingRight: 1,
paddingBottom: 1,
flexDirection: "column",
flexGrow: 1,
}}
>
{children}
</box>
</box>
</>
);
}
36 changes: 2 additions & 34 deletions src/ui/components/chrome/StatusBar.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import type { AppTheme } from "../../themes";
import { fitText } from "../../lib/text";

/** Render either keyboard hints or the active file filter input. */
/** Render the active file filter input or current filter summary. */
export function StatusBar({
canRefresh = false,
canResizeDivider = false,
filter,
filterFocused,
terminalWidth,
Expand All @@ -13,8 +10,6 @@ export function StatusBar({
onFilterInput,
onFilterSubmit,
}: {
canRefresh?: boolean;
canResizeDivider?: boolean;
filter: string;
filterFocused: boolean;
terminalWidth: number;
Expand All @@ -23,28 +18,6 @@ export function StatusBar({
onFilterInput: (value: string) => void;
onFilterSubmit: () => void;
}) {
const hintParts = ["F10 menu"];
if (canResizeDivider) {
hintParts.push("drag divider resize");
}
if (canRefresh) {
hintParts.push("r reload");
}
hintParts.push(
"↑↓ line",
"space/b page",
"/ filter",
"[ ] hunk nav",
"1 2 0 layout",
"s sidebar",
"t theme",
"a notes",
"l lines",
"w wrap",
"m meta",
"q quit",
);

return (
<box
style={{
Expand Down Expand Up @@ -73,12 +46,7 @@ export function StatusBar({
/>
</>
) : (
<text fg={theme.muted}>
{fitText(
`${hintParts.join(" ")}${filter ? ` filter=${filter}` : ""}`,
terminalWidth - 2,
)}
</text>
<text fg={theme.muted}>{`filter=${filter}`}</text>
)}
</box>
);
Expand Down
9 changes: 2 additions & 7 deletions test/app-interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -486,13 +486,13 @@ describe("App interactions", () => {
expect(initialFrame).not.toContain("line08 = 108");

let frame = initialFrame;
for (let index = 0; index < 12; index += 1) {
for (let index = 0; index < 24; index += 1) {
await act(async () => {
await setup.mockInput.pressArrow("down");
});
await flush(setup);
frame = setup.captureCharFrame();
if (frame.includes("line08 = 108")) {
if (frame.includes("line08 = 108") && !frame.includes("line01 = 101")) {
break;
}
}
Expand Down Expand Up @@ -698,7 +698,6 @@ describe("App interactions", () => {
frame = setup.captureCharFrame();
expect(frame).not.toContain("M alpha.ts");
expect(frame).toContain("alpha.ts");
expect(frame).not.toContain("drag divider resize");

await act(async () => {
await setup.mockInput.typeText("s");
Expand All @@ -707,7 +706,6 @@ describe("App interactions", () => {

frame = setup.captureCharFrame();
expect(frame).toContain("M alpha.ts");
expect(frame).toContain("drag divider resize");
} finally {
await act(async () => {
setup.renderer.destroy();
Expand All @@ -726,7 +724,6 @@ describe("App interactions", () => {

let frame = setup.captureCharFrame();
expect(frame).not.toContain("M alpha.ts");
expect(frame).not.toContain("drag divider resize");

await act(async () => {
await setup.mockInput.typeText("s");
Expand All @@ -735,7 +732,6 @@ describe("App interactions", () => {

frame = setup.captureCharFrame();
expect(frame).toContain("M alpha.ts");
expect(frame).toContain("drag divider resize");

await act(async () => {
await setup.mockInput.typeText("s");
Expand All @@ -744,7 +740,6 @@ describe("App interactions", () => {

frame = setup.captureCharFrame();
expect(frame).not.toContain("M alpha.ts");
expect(frame).not.toContain("drag divider resize");
} finally {
await act(async () => {
setup.renderer.destroy();
Expand Down
5 changes: 0 additions & 5 deletions test/app-responsive.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,18 +157,15 @@ describe("responsive shell", () => {

expect(full).toContain("M alpha.ts");
expect(full).not.toContain("Changeset summary");
expect(full).toContain("drag divider resize");
expect(full).toMatch(/▌.*▌/);

expect(medium).not.toContain("Files");
expect(medium).not.toContain("Changeset summary");
expect(medium).toMatch(/▌.*▌/);
expect(medium).not.toContain("drag divider resize");

expect(tight).not.toContain("Files");
expect(tight).not.toContain("Changeset summary");
expect(tight).not.toMatch(/▌.*▌/);
expect(tight).not.toContain("drag divider resize");
});

test("explicit split and stack modes override responsive auto switching", async () => {
Expand All @@ -178,12 +175,10 @@ describe("responsive shell", () => {
expect(forcedSplit).not.toContain("Files");
expect(forcedSplit).not.toContain("Changeset summary");
expect(forcedSplit).toMatch(/▌.*▌/);
expect(forcedSplit).not.toContain("drag divider resize");

expect(forcedStack).toContain("M alpha.ts");
expect(forcedStack).not.toContain("Changeset summary");
expect(forcedStack).not.toMatch(/▌.*▌/);
expect(forcedStack).toContain("drag divider resize");
});

test("pager mode stays responsive while hiding app chrome", async () => {
Expand Down
Loading
Loading