fix(FN-2400): harden dashboard TUI log viewport budgeting

- Rework logs viewport calculation to budget by rendered rows and keep content above footer hints
- Add zero-row safety handling for short terminals with a dedicated expansion hint instead of overlapping output
- Cap wrapped log rendering by remaining row budget so long messages cannot overflow into footer space
- Add regression tests for footer-safe list windowing and wrapped-line truncation, plus a patch changeset for @runfusion/fusion
This commit is contained in:
Fusion
2026-04-24 06:00:00 -07:00
committed by gsxdsm
parent f078a4e946
commit f4d2a4bb58
5 changed files with 160 additions and 32 deletions

View File

@@ -748,7 +748,7 @@ describe("DashboardTUI Logs viewport window", () => {
beforeEach(() => {
tui = createTestTUI();
tui._setTerminalSize(80, 14); // maxRows = 5 in logs list
tui._setTerminalSize(80, 14); // rowBudget = 4 in logs list (footer-safe)
(tui as any).activeSection = "logs";
stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
@@ -782,7 +782,8 @@ describe("DashboardTUI Logs viewport window", () => {
const output = renderLogs();
expect(output).toContain("Entry 5");
expect(output).toContain("Entry 9");
expect(output).toContain("Entry 8");
expect(output).not.toContain("Entry 9");
expect(output).not.toContain("Entry 12");
expect((tui as any).logsViewportStart).toBe(4);
});
@@ -797,7 +798,60 @@ describe("DashboardTUI Logs viewport window", () => {
expect((tui as any).selectedLogIndex).toBe(11);
output = renderLogs();
expect(output).toContain("Entry 12");
expect((tui as any).logsViewportStart).toBe(7);
expect((tui as any).logsViewportStart).toBe(8);
});
});
describe("DashboardTUI Logs footer-safe row budgeting", () => {
let tui: DashboardTUI & {
_stdout: string[];
_setTerminalSize: (cols: number, rows: number) => void;
};
let stdoutWriteSpy: ReturnType<typeof vi.spyOn>;
const stripAnsi = (output: string): string => output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
beforeEach(() => {
tui = createTestTUI();
(tui as any).activeSection = "logs";
(tui as any).isRunning = true;
stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
});
afterEach(() => {
stdoutWriteSpy.mockRestore();
Object.defineProperty(process.stdout, "columns", { value: 80, writable: true });
Object.defineProperty(process.stdout, "rows", { value: 24, writable: true });
});
it("shows a short-terminal hint and suppresses log lines when no safe body rows exist", () => {
tui._setTerminalSize(80, 10); // rowBudget = 0 for log body
for (let i = 1; i <= 6; i++) {
tui.log(`Entry ${i}`);
}
simulateKeypress(tui, "End");
stdoutWriteSpy.mockClear();
(tui as any).renderLogsSection();
const rendered = stripAnsi(stdoutWriteSpy.mock.calls.map(([chunk]) => String(chunk)).join(""));
expect(rendered).toContain("Terminal too short — expand terminal to view logs.");
expect(rendered).not.toContain("Entry 6");
expect(rendered).not.toContain("Entry 1");
});
it("caps wrapped output rows so a single long message cannot overflow footer budget", () => {
tui._setTerminalSize(36, 11); // rowBudget = 1 for log body
tui.log("aa bb cc dd ee ff gg hh ii jj kk ll mm nn");
simulateKeypress(tui, "w");
stdoutWriteSpy.mockClear();
(tui as any).renderLogsSection();
const rendered = stripAnsi(stdoutWriteSpy.mock.calls.map(([chunk]) => String(chunk)).join(""));
// First wrapped segment renders, but continuation rows are suppressed by budget.
expect(rendered).toContain("aa");
expect(rendered).not.toContain("nn");
});
});

View File

@@ -779,32 +779,82 @@ export class DashboardTUI {
}
}
private getLogViewportStart(totalEntries: number, maxRows: number): number {
if (totalEntries <= 0) {
private getFooterTopRow(totalRows: number): number {
return Math.max(1, totalRows - 2);
}
private getLogsListRowBudget(totalRows: number): number {
// In list mode, first log body line starts at row 8:
// 1-2: header, 3: spacer, 4: "LOGS", 5: ring buffer, 6: wrap mode, 7: spacer
// Footer hint is rendered at rows-2, so log body must stay above that row.
const firstLogBodyRow = 8;
const footerTopRow = this.getFooterTopRow(totalRows);
return Math.max(0, footerTopRow - firstLogBodyRow);
}
private getLogEntryRowCount(entry: LogEntry, cols: number): number {
if (!this.logsWrapEnabled) {
return 1;
}
const prefixLen = 30; // timestamp + level + prefix overhead
const availableWidth = Math.max(8, cols - prefixLen);
return Math.max(1, this.wrapText(entry.message, availableWidth).length);
}
private getLogsViewportWindow(entries: LogEntry[], rowBudget: number, cols: number): { start: number; end: number } {
if (entries.length === 0) {
this.logsViewportStart = 0;
return 0;
return { start: 0, end: 0 };
}
const maxStart = Math.max(0, totalEntries - maxRows);
let start = Math.min(this.logsViewportStart, maxStart);
const maxStart = Math.max(0, entries.length - 1);
const safeSelectedIndex = Math.max(0, Math.min(this.selectedLogIndex, entries.length - 1));
let start = Math.max(0, Math.min(this.logsViewportStart, maxStart));
if (this.selectedLogIndex < start) {
start = this.selectedLogIndex;
} else if (this.selectedLogIndex >= start + maxRows) {
start = this.selectedLogIndex - maxRows + 1;
if (safeSelectedIndex < start) {
start = safeSelectedIndex;
}
this.logsViewportStart = Math.max(0, Math.min(start, maxStart));
return this.logsViewportStart;
const measureWindow = (startIndex: number): { end: number } => {
let rowsUsed = 0;
let end = startIndex;
while (end < entries.length) {
const entryRows = this.getLogEntryRowCount(entries[end], cols);
// Always include at least one entry, even if it needs more rows than budget.
if (end > startIndex && rowsUsed + entryRows > rowBudget) {
break;
}
rowsUsed += entryRows;
end++;
if (rowsUsed >= rowBudget) {
break;
}
}
return { end };
};
let window = measureWindow(start);
while (safeSelectedIndex >= window.end && start < maxStart) {
start++;
window = measureWindow(start);
}
this.logsViewportStart = start;
return { start, end: window.end };
}
private renderLogsSection(): void {
const cols = process.stdout.columns || 80;
const rows = process.stdout.rows ?? 38;
const entries = this.logBuffer.getAll();
// Clamp to minimum 1 row to handle very small terminals.
// Reserve 9 rows for fixed UI chrome (header, section labels/spacers, footer)
// so content never overlaps the footer on short terminals.
const maxRows = Math.max(1, (process.stdout.rows ?? 38) - 9);
const rowBudget = this.getLogsListRowBudget(rows);
process.stdout.write(colorize("\n LOGS\n", "bold"));
process.stdout.write(colorize(` Ring buffer: ${this.logBuffer.total}/${MAX_LOG_ENTRIES} entries\n`, "dim"));
@@ -831,13 +881,19 @@ export class DashboardTUI {
const modeIndicator = this.logsWrapEnabled ? colorize(" [w] wrap on", "dim") : colorize(" [w] wrap off", "dim");
process.stdout.write(modeIndicator + "\n\n");
// Calculate viewport window from selection, so every ring-buffer entry remains reachable.
const startIndex = this.getLogViewportStart(entries.length, maxRows);
const visibleEntries = entries.slice(startIndex, startIndex + maxRows);
if (rowBudget === 0) {
process.stdout.write(colorize(" Terminal too short — expand terminal to view logs.\n", "dim"));
return;
}
// Calculate viewport window from selection, keeping entry-based navigation
// while budgeting by printable rows so wrapped content cannot reach footer.
const { start: startIndex, end: endIndex } = this.getLogsViewportWindow(entries, rowBudget, cols);
const visibleEntries = entries.slice(startIndex, endIndex);
const visibleReversed = [...visibleEntries].reverse();
// Map selected index to display index (for highlighting in newest-first list)
const selectedDisplayIndex = safeSelectedIndex >= startIndex && safeSelectedIndex < startIndex + visibleEntries.length
const selectedDisplayIndex = safeSelectedIndex >= startIndex && safeSelectedIndex < endIndex
? visibleEntries.length - 1 - (safeSelectedIndex - startIndex)
: -1;
@@ -845,7 +901,9 @@ export class DashboardTUI {
const prefixLen = 30; // timestamp + level + prefix overhead
const availableWidth = Math.max(8, cols - prefixLen);
for (let displayIdx = 0; displayIdx < visibleReversed.length; displayIdx++) {
let remainingRows = rowBudget;
for (let displayIdx = 0; displayIdx < visibleReversed.length && remainingRows > 0; displayIdx++) {
const entry = visibleReversed[displayIdx];
const isSelected = displayIdx === selectedDisplayIndex;
@@ -858,15 +916,21 @@ export class DashboardTUI {
: colorize("✓", "brightGreen");
if (this.logsWrapEnabled) {
// Wrapped mode: wrap message to available width
// Wrapped mode: wrap message to available width and cap printed rows to footer-safe budget.
const wrappedLines = this.wrapText(entry.message, availableWidth);
const lineBudget = Math.max(1, remainingRows);
const renderedLines = wrappedLines.slice(0, lineBudget);
// First line includes prefix
const firstLine = `${selector}${ts} ${levelChar} ${prefix ? prefix + " " : ""}${wrappedLines[0]}`;
const firstLine = `${selector}${ts} ${levelChar} ${prefix ? prefix + " " : ""}${renderedLines[0] ?? ""}`;
process.stdout.write(visibleTruncate(firstLine, cols - 1) + "\n");
remainingRows--;
// Continuation lines (indented)
for (let i = 1; i < wrappedLines.length; i++) {
const continuation = ` ${wrappedLines[i]}`;
for (let i = 1; i < renderedLines.length && remainingRows > 0; i++) {
const continuation = ` ${renderedLines[i]}`;
process.stdout.write(visibleTruncate(continuation, cols - 1) + "\n");
remainingRows--;
}
} else {
// Single-line mode: truncate to available width
@@ -874,6 +938,7 @@ export class DashboardTUI {
const message = visibleTruncate(entry.message, messageWidth);
const line = `${selector}${ts} ${levelChar} ${prefix ? prefix + " " : ""}${message}`;
process.stdout.write(visibleTruncate(line, cols - 1) + "\n");
remainingRows--;
}
}
}

View File

@@ -2333,7 +2333,8 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(screen.getAllByText("Notifications").length).toBeGreaterThanOrEqual(1);
const notificationsLabels = await screen.findAllByText("Notifications");
expect(notificationsLabels.length).toBeGreaterThanOrEqual(1);
});
it("shows ntfy enable checkbox in Notifications section", async () => {

View File

@@ -4578,8 +4578,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*
* Strategy (in priority order):
* 1. **Branch merge-base** — Prefer the live merge-base between HEAD and
* `origin/{baseBranch}` (or bare `{baseBranch}`). This stays correct as
* the base branch advances and is merged into the feature branch.
* the local `{baseBranch}` ref (falling back to `origin/{baseBranch}`
* when the local ref is missing). The local ref reflects the worktree's
* actual fork point regardless of whether merges have been pushed; using
* `origin/{baseBranch}` first would inflate the diff by every commit
* between `origin/main` and a locally-advanced `main`.
* 2. **Task-scoped baseCommitSha** — Only when no merge-base is available
* (e.g. the base branch was deleted), fall back to the stored SHA if it
* is still an ancestor of HEAD.
@@ -4596,9 +4599,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
let mergeBase: string | undefined;
try {
try {
mergeBase = (await runGitCommand(["merge-base", "HEAD", `origin/${baseBranch}`], cwd, 5000)).trim() || undefined;
} catch {
mergeBase = (await runGitCommand(["merge-base", "HEAD", baseBranch], cwd, 5000)).trim() || undefined;
} catch {
mergeBase = (await runGitCommand(["merge-base", "HEAD", `origin/${baseBranch}`], cwd, 5000)).trim() || undefined;
}
} catch {
// base branch may no longer exist locally