diff --git a/src/ui/View.tsx b/src/ui/View.tsx
index fd73c40..3b1de9a 100644
--- a/src/ui/View.tsx
+++ b/src/ui/View.tsx
@@ -9,6 +9,7 @@ import { useProcessManager } from "../hooks/useProcessManager";
import { ProgramStateProvider, useProgramState, ProgramStatus } from "../hooks/useProgramState";
import { Colors } from "../lib/Colors";
import type { CurseConfig } from "../parser";
+import { MIN_LOG_PREVIEW_HEIGHT, PROCESS_TABLE_OVERHEAD, VIEW_HEADER_HEIGHT } from "./layout";
import { LogPage } from "./views/LogPage";
import { MainPage, type DisplayMode } from "./views/MainPage";
@@ -32,7 +33,9 @@ function View(props: { config: CurseConfig }) {
const terminalHeight = stdout?.rows ?? 24;
const processCount = processesRef.current.length;
- const normalMinHeight = processCount + 13; // header(4) + table(N+3) + logPreview(5) + footer(1)
+ // header + processTable(N + overhead) + minLogPreview + collapsed footer (1 row)
+ const normalMinHeight =
+ VIEW_HEADER_HEIGHT + processCount + PROCESS_TABLE_OVERHEAD + MIN_LOG_PREVIEW_HEIGHT + 1;
const compactMinHeight = processCount + 1;
const displayMode: DisplayMode =
terminalHeight < compactMinHeight
diff --git a/src/ui/components/ShortcutFooter.tsx b/src/ui/components/ShortcutFooter.tsx
index a5a1102..ce11d57 100644
--- a/src/ui/components/ShortcutFooter.tsx
+++ b/src/ui/components/ShortcutFooter.tsx
@@ -1,6 +1,10 @@
import { Box, Text, useStdout } from "ink";
import { Colors } from "../../lib/Colors";
+import { getShortcutFooterColumns, getShortcutFooterHeight } from "../layout";
+
+// Re-export so existing callers can keep importing it from this module.
+export { getShortcutFooterHeight };
interface ShortcutFooterProps {
shortcuts: string[];
@@ -15,13 +19,7 @@ export function ShortcutFooter({ shortcuts, showShortcuts }: ShortcutFooterProps
{showShortcuts ? (
(() => {
- // Calculate number of columns based on terminal width
- let numColumns;
- if (terminalWidth < 80) numColumns = 1;
- else if (terminalWidth < 120) numColumns = 2;
- else if (terminalWidth < 160) numColumns = 3;
- else numColumns = 4;
-
+ const numColumns = getShortcutFooterColumns(terminalWidth);
const itemsPerColumn = Math.ceil(shortcuts.length / numColumns);
const columns = [];
@@ -49,20 +47,3 @@ export function ShortcutFooter({ shortcuts, showShortcuts }: ShortcutFooterProps
);
}
-
-// Helper function to calculate the height taken by shortcuts
-export function getShortcutFooterHeight(
- shortcutsCount: number,
- terminalWidth: number,
- showShortcuts: boolean,
-): number {
- if (!showShortcuts) return 0;
-
- let numColumns;
- if (terminalWidth < 80) numColumns = 1;
- else if (terminalWidth < 120) numColumns = 2;
- else if (terminalWidth < 160) numColumns = 3;
- else numColumns = 4;
-
- return Math.ceil(shortcutsCount / numColumns);
-}
diff --git a/src/ui/layout.test.ts b/src/ui/layout.test.ts
new file mode 100644
index 0000000..879bbbc
--- /dev/null
+++ b/src/ui/layout.test.ts
@@ -0,0 +1,258 @@
+import { describe, it, expect } from "bun:test";
+
+import {
+ computeLogPageLayout,
+ computeMainPageLayout,
+ getShortcutFooterColumns,
+ getShortcutFooterHeight,
+ LOG_PAGE_TITLE_HEIGHT,
+ MIN_LOG_PREVIEW_HEIGHT,
+ PROCESS_TABLE_OVERHEAD,
+ SEARCH_BAR_HEIGHT,
+ VIEW_HEADER_HEIGHT,
+} from "./layout";
+
+describe("getShortcutFooterColumns", () => {
+ it("uses 1 column when terminalWidth < 80", () => {
+ expect(getShortcutFooterColumns(40)).toBe(1);
+ expect(getShortcutFooterColumns(79)).toBe(1);
+ });
+
+ it("uses 2 columns for 80 <= width < 120", () => {
+ expect(getShortcutFooterColumns(80)).toBe(2);
+ expect(getShortcutFooterColumns(119)).toBe(2);
+ });
+
+ it("uses 3 columns for 120 <= width < 160", () => {
+ expect(getShortcutFooterColumns(120)).toBe(3);
+ expect(getShortcutFooterColumns(159)).toBe(3);
+ });
+
+ it("uses 4 columns for width >= 160", () => {
+ expect(getShortcutFooterColumns(160)).toBe(4);
+ expect(getShortcutFooterColumns(400)).toBe(4);
+ });
+});
+
+describe("getShortcutFooterHeight", () => {
+ // Regression: getShortcutFooterHeight previously returned 0 when shortcuts
+ // were collapsed, but the footer always renders the "? for shortcuts" row.
+ // That mismatch wasted 1 terminal row at the bottom of the screen.
+ it("returns 1 when shortcuts are hidden (the '? for shortcuts' row)", () => {
+ expect(getShortcutFooterHeight(9, 80, false)).toBe(1);
+ expect(getShortcutFooterHeight(0, 80, false)).toBe(1);
+ expect(getShortcutFooterHeight(20, 200, false)).toBe(1);
+ });
+
+ it("returns ceil(count / columns) when shortcuts are shown", () => {
+ // 80 width => 2 columns, 9 shortcuts => ceil(9 / 2) = 5
+ expect(getShortcutFooterHeight(9, 80, true)).toBe(5);
+ // 120 width => 3 columns, 9 shortcuts => 3
+ expect(getShortcutFooterHeight(9, 120, true)).toBe(3);
+ // 160 width => 4 columns, 9 shortcuts => 3
+ expect(getShortcutFooterHeight(9, 160, true)).toBe(3);
+ // 60 width => 1 column, 12 shortcuts => 12
+ expect(getShortcutFooterHeight(12, 60, true)).toBe(12);
+ });
+});
+
+describe("computeMainPageLayout", () => {
+ // Regression: previously the four heights summed to terminalHeight - 3,
+ // leaving three wasted rows at the bottom of the screen below the
+ // shortcut footer. The four sources of the gap were:
+ // - headerHeight hardcoded to 4 (actual View header is 2 rows)
+ // - an extra `- 1` safety buffer in the log preview height
+ // - getShortcutFooterHeight returning 0 when collapsed (vs. 1 actual)
+ // - LogPage's separate `6` constant being similarly miscounted
+ it("heights sum to terminalHeight when shortcuts are collapsed", () => {
+ const layout = computeMainPageLayout({
+ terminalHeight: 40,
+ terminalWidth: 120,
+ processCount: 5,
+ shortcutsCount: 9,
+ showShortcuts: false,
+ });
+ const total =
+ layout.headerHeight +
+ layout.processTableHeight +
+ layout.logPreviewHeight +
+ layout.shortcutFooterHeight;
+ expect(total).toBe(40);
+ });
+
+ it("heights sum to terminalHeight when shortcuts are expanded", () => {
+ const layout = computeMainPageLayout({
+ terminalHeight: 40,
+ terminalWidth: 120,
+ processCount: 5,
+ shortcutsCount: 9,
+ showShortcuts: true,
+ });
+ const total =
+ layout.headerHeight +
+ layout.processTableHeight +
+ layout.logPreviewHeight +
+ layout.shortcutFooterHeight;
+ expect(total).toBe(40);
+ });
+
+ it("uses correct fixed heights", () => {
+ const layout = computeMainPageLayout({
+ terminalHeight: 40,
+ terminalWidth: 120,
+ processCount: 5,
+ shortcutsCount: 9,
+ showShortcuts: false,
+ });
+ expect(layout.headerHeight).toBe(VIEW_HEADER_HEIGHT);
+ expect(layout.processTableHeight).toBe(5 + PROCESS_TABLE_OVERHEAD);
+ expect(layout.shortcutFooterHeight).toBe(1);
+ });
+
+ it("scales with process count and gives the rest to the log preview", () => {
+ const a = computeMainPageLayout({
+ terminalHeight: 40,
+ terminalWidth: 120,
+ processCount: 3,
+ shortcutsCount: 9,
+ showShortcuts: false,
+ });
+ const b = computeMainPageLayout({
+ terminalHeight: 40,
+ terminalWidth: 120,
+ processCount: 7,
+ shortcutsCount: 9,
+ showShortcuts: false,
+ });
+ expect(a.logPreviewHeight - b.logPreviewHeight).toBe(4);
+ });
+
+ it("expanding shortcuts shrinks the log preview by the same number of rows", () => {
+ const collapsed = computeMainPageLayout({
+ terminalHeight: 40,
+ terminalWidth: 120,
+ processCount: 3,
+ shortcutsCount: 9,
+ showShortcuts: false,
+ });
+ const expanded = computeMainPageLayout({
+ terminalHeight: 40,
+ terminalWidth: 120,
+ processCount: 3,
+ shortcutsCount: 9,
+ showShortcuts: true,
+ });
+ const footerDelta = expanded.shortcutFooterHeight - collapsed.shortcutFooterHeight;
+ expect(collapsed.logPreviewHeight - expanded.logPreviewHeight).toBe(footerDelta);
+ });
+
+ it("clamps the log preview to the minimum when the terminal is tight", () => {
+ const layout = computeMainPageLayout({
+ terminalHeight: 10, // very small
+ terminalWidth: 120,
+ processCount: 5,
+ shortcutsCount: 9,
+ showShortcuts: false,
+ });
+ expect(layout.logPreviewHeight).toBe(MIN_LOG_PREVIEW_HEIGHT);
+ });
+});
+
+describe("computeLogPageLayout", () => {
+ it("heights sum to terminalHeight when shortcuts are collapsed and not searching", () => {
+ const layout = computeLogPageLayout({
+ terminalHeight: 40,
+ terminalWidth: 120,
+ shortcutsCount: 12,
+ showShortcuts: false,
+ isSearchMode: false,
+ });
+ const total =
+ layout.headerHeight +
+ layout.titleHeight +
+ layout.searchBarHeight +
+ layout.logTableHeight +
+ layout.shortcutFooterHeight;
+ expect(total).toBe(40);
+ });
+
+ it("heights sum to terminalHeight when shortcuts are expanded", () => {
+ const layout = computeLogPageLayout({
+ terminalHeight: 40,
+ terminalWidth: 120,
+ shortcutsCount: 12,
+ showShortcuts: true,
+ isSearchMode: false,
+ });
+ const total =
+ layout.headerHeight +
+ layout.titleHeight +
+ layout.searchBarHeight +
+ layout.logTableHeight +
+ layout.shortcutFooterHeight;
+ expect(total).toBe(40);
+ });
+
+ it("heights sum to terminalHeight in search mode", () => {
+ const layout = computeLogPageLayout({
+ terminalHeight: 40,
+ terminalWidth: 120,
+ shortcutsCount: 12,
+ showShortcuts: false,
+ isSearchMode: true,
+ });
+ const total =
+ layout.headerHeight +
+ layout.titleHeight +
+ layout.searchBarHeight +
+ layout.logTableHeight +
+ layout.shortcutFooterHeight;
+ expect(total).toBe(40);
+ expect(layout.searchBarHeight).toBe(SEARCH_BAR_HEIGHT);
+ });
+
+ it("uses correct fixed heights", () => {
+ const layout = computeLogPageLayout({
+ terminalHeight: 40,
+ terminalWidth: 120,
+ shortcutsCount: 12,
+ showShortcuts: false,
+ isSearchMode: false,
+ });
+ expect(layout.headerHeight).toBe(VIEW_HEADER_HEIGHT);
+ expect(layout.titleHeight).toBe(LOG_PAGE_TITLE_HEIGHT);
+ expect(layout.searchBarHeight).toBe(0);
+ expect(layout.shortcutFooterHeight).toBe(1);
+ });
+
+ it("entering search mode reduces the log table by exactly one row", () => {
+ const inputs = {
+ terminalHeight: 40,
+ terminalWidth: 120,
+ shortcutsCount: 12,
+ showShortcuts: false,
+ };
+ const noSearch = computeLogPageLayout({ ...inputs, isSearchMode: false });
+ const searching = computeLogPageLayout({ ...inputs, isSearchMode: true });
+ expect(noSearch.logTableHeight - searching.logTableHeight).toBe(1);
+ });
+
+ it("expanding shortcuts shrinks the log table by the same number of rows", () => {
+ const collapsed = computeLogPageLayout({
+ terminalHeight: 40,
+ terminalWidth: 120,
+ shortcutsCount: 12,
+ showShortcuts: false,
+ isSearchMode: false,
+ });
+ const expanded = computeLogPageLayout({
+ terminalHeight: 40,
+ terminalWidth: 120,
+ shortcutsCount: 12,
+ showShortcuts: true,
+ isSearchMode: false,
+ });
+ const footerDelta = expanded.shortcutFooterHeight - collapsed.shortcutFooterHeight;
+ expect(collapsed.logTableHeight - expanded.logTableHeight).toBe(footerDelta);
+ });
+});
diff --git a/src/ui/layout.ts b/src/ui/layout.ts
new file mode 100644
index 0000000..6b9a806
--- /dev/null
+++ b/src/ui/layout.ts
@@ -0,0 +1,130 @@
+// Pure layout math for the TUI. Kept free of any ink imports so it can be
+// unit-tested directly. The heights here describe the number of terminal rows
+// each component renders, and the sum of all components for a given page must
+// equal `terminalHeight` so no rows are wasted at the bottom.
+
+/** "Curse v{version}" + "Config: {filename}" — rendered by `View`. */
+export const VIEW_HEADER_HEIGHT = 2;
+
+/** ProcessTable border-and-header overhead: top border + header row + bottom border. */
+export const PROCESS_TABLE_OVERHEAD = 3;
+
+/** "Logs({name})[tail]" title bar in the log view. */
+export const LOG_PAGE_TITLE_HEIGHT = 1;
+
+/** Search bar row, when search mode is active. */
+export const SEARCH_BAR_HEIGHT = 1;
+
+/** Floor for the log preview on the main page so it stays usable. */
+export const MIN_LOG_PREVIEW_HEIGHT = 4;
+
+/**
+ * Number of columns the shortcut footer breaks into based on terminal width.
+ * Mirrors the layout used inside `ShortcutFooter`.
+ */
+export function getShortcutFooterColumns(terminalWidth: number): number {
+ if (terminalWidth < 80) return 1;
+ if (terminalWidth < 120) return 2;
+ if (terminalWidth < 160) return 3;
+ return 4;
+}
+
+/**
+ * Number of rows the shortcut footer actually renders.
+ *
+ * When `showShortcuts` is false the footer still renders a single
+ * "? for shortcuts" row — historically this returned 0 here, which was the
+ * source of the wasted-row gap at the bottom of the terminal.
+ */
+export function getShortcutFooterHeight(
+ shortcutsCount: number,
+ terminalWidth: number,
+ showShortcuts: boolean,
+): number {
+ if (!showShortcuts) return 1;
+ const numColumns = getShortcutFooterColumns(terminalWidth);
+ return Math.ceil(shortcutsCount / numColumns);
+}
+
+export interface MainPageLayoutInput {
+ terminalHeight: number;
+ terminalWidth: number;
+ processCount: number;
+ shortcutsCount: number;
+ showShortcuts: boolean;
+}
+
+export interface MainPageLayout {
+ headerHeight: number;
+ processTableHeight: number;
+ logPreviewHeight: number;
+ shortcutFooterHeight: number;
+}
+
+/**
+ * Compute the row breakdown for the MainPage in normal display mode.
+ *
+ * Invariant (when terminalHeight is large enough): the four returned heights
+ * sum to exactly `terminalHeight`, leaving no empty rows at the bottom.
+ */
+export function computeMainPageLayout(input: MainPageLayoutInput): MainPageLayout {
+ const headerHeight = VIEW_HEADER_HEIGHT;
+ const processTableHeight = input.processCount + PROCESS_TABLE_OVERHEAD;
+ const shortcutFooterHeight = getShortcutFooterHeight(
+ input.shortcutsCount,
+ input.terminalWidth,
+ input.showShortcuts,
+ );
+ const available = input.terminalHeight - headerHeight - processTableHeight - shortcutFooterHeight;
+ const logPreviewHeight = Math.max(MIN_LOG_PREVIEW_HEIGHT, available);
+
+ return {
+ headerHeight,
+ processTableHeight,
+ logPreviewHeight,
+ shortcutFooterHeight,
+ };
+}
+
+export interface LogPageLayoutInput {
+ terminalHeight: number;
+ terminalWidth: number;
+ shortcutsCount: number;
+ showShortcuts: boolean;
+ isSearchMode: boolean;
+}
+
+export interface LogPageLayout {
+ headerHeight: number;
+ titleHeight: number;
+ searchBarHeight: number;
+ logTableHeight: number;
+ shortcutFooterHeight: number;
+}
+
+/**
+ * Compute the row breakdown for the LogPage.
+ *
+ * Invariant: the five returned heights sum to exactly `terminalHeight`,
+ * leaving no empty rows at the bottom.
+ */
+export function computeLogPageLayout(input: LogPageLayoutInput): LogPageLayout {
+ const headerHeight = VIEW_HEADER_HEIGHT;
+ const titleHeight = LOG_PAGE_TITLE_HEIGHT;
+ const searchBarHeight = input.isSearchMode ? SEARCH_BAR_HEIGHT : 0;
+ const shortcutFooterHeight = getShortcutFooterHeight(
+ input.shortcutsCount,
+ input.terminalWidth,
+ input.showShortcuts,
+ );
+ const logTableHeight =
+ input.terminalHeight - headerHeight - titleHeight - searchBarHeight - shortcutFooterHeight;
+
+ return {
+ headerHeight,
+ titleHeight,
+ searchBarHeight,
+ logTableHeight,
+ shortcutFooterHeight,
+ };
+}
diff --git a/src/ui/views/LogPage.tsx b/src/ui/views/LogPage.tsx
index 8082091..8cd53bd 100644
--- a/src/ui/views/LogPage.tsx
+++ b/src/ui/views/LogPage.tsx
@@ -9,7 +9,8 @@ import { useProgramState, ProgramStatus } from "../../hooks/useProgramState";
import { useRenderTick } from "../../hooks/useRenderTick";
import { Colors } from "../../lib/Colors";
import { preprocessLog } from "../../lib/LogProcessing";
-import { ShortcutFooter, getShortcutFooterHeight } from "../components/ShortcutFooter";
+import { ShortcutFooter } from "../components/ShortcutFooter";
+import { computeLogPageLayout } from "../layout";
function LogTable(props: {
height: number;
@@ -820,10 +821,13 @@ export function LogPage() {
)}