Skip to content

Commit ba09e75

Browse files
authored
ui: replace footer hints with keyboard help modal (#88)
1 parent 1f06574 commit ba09e75

7 files changed

Lines changed: 184 additions & 92 deletions

File tree

src/ui/App.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -508,8 +508,6 @@ function AppShell({
508508
0,
509509
);
510510
const topTitle = `${bootstrap.changeset.title} +${totalAdditions} -${totalDeletions}`;
511-
const helpWidth = Math.min(68, Math.max(44, terminal.width - 8));
512-
const helpLeft = Math.max(1, Math.floor((terminal.width - helpWidth) / 2));
513511
const filesTextWidth = Math.max(8, clampedFilesPaneWidth - 4);
514512
const diffContentWidth = Math.max(12, diffPaneWidth - 2);
515513
const diffHeaderStatsWidth = Math.min(24, Math.max(16, Math.floor(diffContentWidth / 3)));
@@ -856,10 +854,8 @@ function AppShell({
856854
/>
857855
</box>
858856

859-
{!pagerMode ? (
857+
{!pagerMode && (focusArea === "filter" || Boolean(filter)) ? (
860858
<StatusBar
861-
canRefresh={canRefreshCurrentInput}
862-
canResizeDivider={showFilesPane}
863859
filter={filter}
864860
filterFocused={focusArea === "filter"}
865861
terminalWidth={terminal.width}
@@ -892,9 +888,9 @@ function AppShell({
892888
<Suspense fallback={null}>
893889
<LazyHelpDialog
894890
canRefresh={canRefreshCurrentInput}
895-
left={helpLeft}
891+
terminalHeight={terminal.height}
892+
terminalWidth={terminal.width}
896893
theme={activeTheme}
897-
width={helpWidth}
898894
onClose={() => setShowHelp(false)}
899895
/>
900896
</Suspense>
Lines changed: 66 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,81 @@
1+
import { fitText, padText } from "../../lib/text";
12
import type { AppTheme } from "../../themes";
3+
import { ModalFrame } from "./ModalFrame";
24

3-
/** Render the compact keyboard help overlay. */
5+
/** Render the keyboard help modal. */
46
export function HelpDialog({
57
canRefresh = false,
6-
left,
8+
terminalHeight,
9+
terminalWidth,
710
theme,
8-
width,
911
onClose,
1012
}: {
1113
canRefresh?: boolean;
12-
left: number;
14+
terminalHeight: number;
15+
terminalWidth: number;
1316
theme: AppTheme;
14-
width: number;
1517
onClose: () => void;
1618
}) {
19+
const sections = [
20+
{
21+
title: "Navigation",
22+
items: [
23+
["↑ / ↓", "move line-by-line"],
24+
["Space / b", "page down / page up"],
25+
["[ / ]", "previous / next hunk"],
26+
["Home / End", "jump to top / bottom"],
27+
],
28+
},
29+
{
30+
title: "View",
31+
items: [
32+
["1 / 2 / 0", "split / stack / auto"],
33+
["s / t", "sidebar / theme"],
34+
["a", "toggle AI notes"],
35+
["l / w / m", "lines / wrap / metadata"],
36+
],
37+
},
38+
{
39+
title: "Review",
40+
items: [
41+
["/", "focus file filter"],
42+
["Tab", "swap files / filter focus"],
43+
["F10", "open menus"],
44+
[canRefresh ? "r / q" : "q", canRefresh ? "reload / quit" : "quit"],
45+
],
46+
},
47+
] as const;
48+
49+
const width = Math.min(74, Math.max(56, terminalWidth - 8));
50+
const bodyWidth = Math.max(1, width - 4);
51+
const keyWidth = Math.min(16, Math.max(12, Math.floor(bodyWidth * 0.28)));
52+
const descriptionWidth = Math.max(1, bodyWidth - keyWidth);
53+
const height = Math.min(
54+
sections.reduce((total, section) => total + 1 + section.items.length, 0) + 3,
55+
Math.max(8, terminalHeight - 2),
56+
);
57+
1758
return (
18-
<box
19-
style={{
20-
position: "absolute",
21-
top: 3,
22-
left,
23-
width,
24-
height: canRefresh ? 10 : 9,
25-
zIndex: 60,
26-
border: true,
27-
borderColor: theme.accent,
28-
backgroundColor: theme.panel,
29-
padding: 1,
30-
flexDirection: "column",
31-
gap: 1,
32-
}}
33-
onMouseUp={onClose}
59+
<ModalFrame
60+
height={height}
61+
terminalHeight={terminalHeight}
62+
terminalWidth={terminalWidth}
63+
theme={theme}
64+
title="Keyboard help"
65+
width={width}
66+
onClose={onClose}
3467
>
35-
<text fg={theme.text}>Keyboard</text>
36-
<text fg={theme.muted}>F10 menus arrows navigate menus Enter select Esc close menu</text>
37-
<text fg={theme.muted}>1 split 2 stack 0 auto t theme a notes l lines w wrap m meta</text>
38-
{canRefresh ? <text fg={theme.muted}>r reload the current diff</text> : null}
39-
<text fg={theme.muted}>
40-
↑/↓ line scroll space next page b previous page Home/End jump [ previous hunk ] next hunk
41-
</text>
42-
<text fg={theme.muted}>drag the Files/Diff divider with the mouse to resize the columns</text>
43-
<text fg={theme.muted}>/ focus filter Tab swap files/filter q quit</text>
44-
<text fg={theme.badgeNeutral}>click anywhere on this panel to close</text>
45-
</box>
68+
{sections.map((section) => (
69+
<box key={section.title} style={{ flexDirection: "column" }}>
70+
<text fg={theme.badgeNeutral}>{section.title}</text>
71+
{section.items.map(([keys, description]) => (
72+
<box key={`${section.title}:${keys}`} style={{ flexDirection: "row" }}>
73+
<text fg={theme.accent}>{padText(fitText(keys, keyWidth), keyWidth)}</text>
74+
<text fg={theme.muted}>{fitText(description, descriptionWidth)}</text>
75+
</box>
76+
))}
77+
</box>
78+
))}
79+
</ModalFrame>
4680
);
4781
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { MouseEvent as TuiMouseEvent } from "@opentui/core";
2+
import type { ReactNode } from "react";
3+
import { fitText, padText } from "../../lib/text";
4+
import type { AppTheme } from "../../themes";
5+
6+
/** Render a centered framed modal shell that other dialogs can reuse. */
7+
export function ModalFrame({
8+
children,
9+
height,
10+
onClose,
11+
terminalHeight,
12+
terminalWidth,
13+
theme,
14+
title,
15+
width,
16+
}: {
17+
children: ReactNode;
18+
height: number;
19+
onClose?: () => void;
20+
terminalHeight: number;
21+
terminalWidth: number;
22+
theme: AppTheme;
23+
title: string;
24+
width: number;
25+
}) {
26+
const clampedWidth = Math.min(width, Math.max(24, terminalWidth - 2));
27+
const clampedHeight = Math.min(height, Math.max(5, terminalHeight - 2));
28+
const left = Math.max(1, Math.floor((terminalWidth - clampedWidth) / 2));
29+
const top = Math.max(1, Math.floor((terminalHeight - clampedHeight) / 2));
30+
const closeText = onClose ? "[Esc]" : "";
31+
const titleWidth = Math.max(1, clampedWidth - 2 - (closeText ? closeText.length + 1 : 0));
32+
33+
return (
34+
<>
35+
<box
36+
style={{
37+
position: "absolute",
38+
top: 0,
39+
left: 0,
40+
width: terminalWidth,
41+
height: terminalHeight,
42+
zIndex: 55,
43+
}}
44+
onMouseUp={onClose}
45+
/>
46+
<box
47+
style={{
48+
position: "absolute",
49+
top,
50+
left,
51+
width: clampedWidth,
52+
height: clampedHeight,
53+
zIndex: 60,
54+
border: true,
55+
borderColor: theme.accent,
56+
backgroundColor: theme.panel,
57+
flexDirection: "column",
58+
}}
59+
onMouseUp={(event: TuiMouseEvent) => event.stopPropagation()}
60+
>
61+
<box
62+
style={{
63+
paddingLeft: 1,
64+
paddingRight: 1,
65+
paddingTop: 1,
66+
flexDirection: "row",
67+
}}
68+
>
69+
<text fg={theme.text}>{padText(fitText(title, titleWidth), titleWidth)}</text>
70+
{closeText ? (
71+
<box
72+
onMouseUp={(event: TuiMouseEvent) => {
73+
event.stopPropagation();
74+
onClose?.();
75+
}}
76+
>
77+
<text fg={theme.badgeNeutral}>{closeText}</text>
78+
</box>
79+
) : null}
80+
</box>
81+
<box
82+
style={{
83+
paddingLeft: 1,
84+
paddingRight: 1,
85+
paddingBottom: 1,
86+
flexDirection: "column",
87+
flexGrow: 1,
88+
}}
89+
>
90+
{children}
91+
</box>
92+
</box>
93+
</>
94+
);
95+
}

src/ui/components/chrome/StatusBar.tsx

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import type { AppTheme } from "../../themes";
2-
import { fitText } from "../../lib/text";
32

4-
/** Render either keyboard hints or the active file filter input. */
3+
/** Render the active file filter input or current filter summary. */
54
export function StatusBar({
6-
canRefresh = false,
7-
canResizeDivider = false,
85
filter,
96
filterFocused,
107
terminalWidth,
@@ -13,8 +10,6 @@ export function StatusBar({
1310
onFilterInput,
1411
onFilterSubmit,
1512
}: {
16-
canRefresh?: boolean;
17-
canResizeDivider?: boolean;
1813
filter: string;
1914
filterFocused: boolean;
2015
terminalWidth: number;
@@ -23,28 +18,6 @@ export function StatusBar({
2318
onFilterInput: (value: string) => void;
2419
onFilterSubmit: () => void;
2520
}) {
26-
const hintParts = ["F10 menu"];
27-
if (canResizeDivider) {
28-
hintParts.push("drag divider resize");
29-
}
30-
if (canRefresh) {
31-
hintParts.push("r reload");
32-
}
33-
hintParts.push(
34-
"↑↓ line",
35-
"space/b page",
36-
"/ filter",
37-
"[ ] hunk nav",
38-
"1 2 0 layout",
39-
"s sidebar",
40-
"t theme",
41-
"a notes",
42-
"l lines",
43-
"w wrap",
44-
"m meta",
45-
"q quit",
46-
);
47-
4821
return (
4922
<box
5023
style={{
@@ -73,12 +46,7 @@ export function StatusBar({
7346
/>
7447
</>
7548
) : (
76-
<text fg={theme.muted}>
77-
{fitText(
78-
`${hintParts.join(" ")}${filter ? ` filter=${filter}` : ""}`,
79-
terminalWidth - 2,
80-
)}
81-
</text>
49+
<text fg={theme.muted}>{`filter=${filter}`}</text>
8250
)}
8351
</box>
8452
);

test/app-interactions.test.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -486,13 +486,13 @@ describe("App interactions", () => {
486486
expect(initialFrame).not.toContain("line08 = 108");
487487

488488
let frame = initialFrame;
489-
for (let index = 0; index < 12; index += 1) {
489+
for (let index = 0; index < 24; index += 1) {
490490
await act(async () => {
491491
await setup.mockInput.pressArrow("down");
492492
});
493493
await flush(setup);
494494
frame = setup.captureCharFrame();
495-
if (frame.includes("line08 = 108")) {
495+
if (frame.includes("line08 = 108") && !frame.includes("line01 = 101")) {
496496
break;
497497
}
498498
}
@@ -698,7 +698,6 @@ describe("App interactions", () => {
698698
frame = setup.captureCharFrame();
699699
expect(frame).not.toContain("M alpha.ts");
700700
expect(frame).toContain("alpha.ts");
701-
expect(frame).not.toContain("drag divider resize");
702701

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

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

727725
let frame = setup.captureCharFrame();
728726
expect(frame).not.toContain("M alpha.ts");
729-
expect(frame).not.toContain("drag divider resize");
730727

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

736733
frame = setup.captureCharFrame();
737734
expect(frame).toContain("M alpha.ts");
738-
expect(frame).toContain("drag divider resize");
739735

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

745741
frame = setup.captureCharFrame();
746742
expect(frame).not.toContain("M alpha.ts");
747-
expect(frame).not.toContain("drag divider resize");
748743
} finally {
749744
await act(async () => {
750745
setup.renderer.destroy();

test/app-responsive.test.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,18 +157,15 @@ describe("responsive shell", () => {
157157

158158
expect(full).toContain("M alpha.ts");
159159
expect(full).not.toContain("Changeset summary");
160-
expect(full).toContain("drag divider resize");
161160
expect(full).toMatch(/.*/);
162161

163162
expect(medium).not.toContain("Files");
164163
expect(medium).not.toContain("Changeset summary");
165164
expect(medium).toMatch(/.*/);
166-
expect(medium).not.toContain("drag divider resize");
167165

168166
expect(tight).not.toContain("Files");
169167
expect(tight).not.toContain("Changeset summary");
170168
expect(tight).not.toMatch(/.*/);
171-
expect(tight).not.toContain("drag divider resize");
172169
});
173170

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

183179
expect(forcedStack).toContain("M alpha.ts");
184180
expect(forcedStack).not.toContain("Changeset summary");
185181
expect(forcedStack).not.toMatch(/.*/);
186-
expect(forcedStack).toContain("drag divider resize");
187182
});
188183

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

0 commit comments

Comments
 (0)