fix(FN-2312): stabilize dashboard TUI log navigation and merge log streaming

- Keep the Logs tab viewport anchored to selection so users can navigate the full in-memory ring buffer with arrow keys and Home/End
- Route streamed merge agent text through the dashboard log sink via a buffered line assembler to avoid raw fragment writes in TTY mode
- Expand dashboard and dashboard-tui command tests to cover viewport behavior, expanded log interactions, and streamed merge log handling
- Update CLI reference docs and add a @runfusion/fusion patch changeset describing the TUI log behavior fixes
This commit is contained in:
Fusion
2026-04-23 11:32:26 -07:00
committed by gsxdsm
parent e77dfffc9b
commit df2836c01d
6 changed files with 327 additions and 33 deletions

View File

@@ -504,6 +504,68 @@ describe("DashboardTUI Logs Selection", () => {
});
});
describe("DashboardTUI Logs viewport window", () => {
let tui: DashboardTUI & {
_stdout: string[];
_setTerminalSize: (cols: number, rows: number) => void;
};
let stdoutWriteSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
tui = createTestTUI();
tui._setTerminalSize(80, 14); // maxRows = 5 in logs list
(tui as any).activeSection = "logs";
stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
(tui as any).isRunning = true;
for (let i = 1; i <= 12; i++) {
tui.log(`Entry ${i}`);
}
});
afterEach(() => {
stdoutWriteSpy.mockRestore();
Object.defineProperty(process.stdout, "columns", { value: 80, writable: true });
Object.defineProperty(process.stdout, "rows", { value: 24, writable: true });
});
function renderLogs(): string {
stdoutWriteSpy.mockClear();
(tui as any).renderLogsSection();
return stdoutWriteSpy.mock.calls.map(([chunk]) => String(chunk)).join("");
}
it("keeps the selected older entry visible when navigating beyond newest screenful", () => {
// Jump to newest entry (index 11), then move to older entries.
simulateKeypress(tui, "End");
for (let i = 0; i < 7; i++) {
simulateKeypress(tui, "\x1b[A");
}
expect((tui as any).selectedLogIndex).toBe(4);
const output = renderLogs();
expect(output).toContain("Entry 5");
expect(output).toContain("Entry 9");
expect(output).not.toContain("Entry 12");
expect((tui as any).logsViewportStart).toBe(4);
});
it("Home and End reach full ring buffer bounds from the logs tab", () => {
simulateKeypress(tui, "Home");
expect((tui as any).selectedLogIndex).toBe(0);
let output = renderLogs();
expect(output).toContain("Entry 1");
simulateKeypress(tui, "End");
expect((tui as any).selectedLogIndex).toBe(11);
output = renderLogs();
expect(output).toContain("Entry 12");
expect((tui as any).logsViewportStart).toBe(7);
});
});
describe("DashboardTUI Logs Expanded Mode", () => {
let tui: DashboardTUI & {
_stdout: string[];

View File

@@ -276,6 +276,7 @@ export class DashboardTUI {
// Logs interaction state
private selectedLogIndex = 0;
private logsViewportStart = 0;
private logsWrapEnabled = false;
private logsExpandedMode = false;
@@ -328,6 +329,7 @@ export class DashboardTUI {
clearLogs(): void {
this.logBuffer.clear();
this.selectedLogIndex = 0;
this.logsViewportStart = 0;
this.logsExpandedMode = false;
}
@@ -777,6 +779,25 @@ export class DashboardTUI {
}
}
private getLogViewportStart(totalEntries: number, maxRows: number): number {
if (totalEntries <= 0) {
this.logsViewportStart = 0;
return 0;
}
const maxStart = Math.max(0, totalEntries - maxRows);
let start = Math.min(this.logsViewportStart, maxStart);
if (this.selectedLogIndex < start) {
start = this.selectedLogIndex;
} else if (this.selectedLogIndex >= start + maxRows) {
start = this.selectedLogIndex - maxRows + 1;
}
this.logsViewportStart = Math.max(0, Math.min(start, maxStart));
return this.logsViewportStart;
}
private renderLogsSection(): void {
const cols = process.stdout.columns || 80;
const entries = this.logBuffer.getAll();
@@ -795,6 +816,9 @@ export class DashboardTUI {
// Clamp selection index to valid range
const safeSelectedIndex = Math.min(this.selectedLogIndex, Math.max(0, entries.length - 1));
if (safeSelectedIndex !== this.selectedLogIndex) {
this.selectedLogIndex = safeSelectedIndex;
}
// If expanded mode is on, render the detail pane
if (this.logsExpandedMode) {
@@ -807,14 +831,14 @@ export class DashboardTUI {
const modeIndicator = this.logsWrapEnabled ? colorize(" [w] wrap on", "dim") : colorize(" [w] wrap off", "dim");
process.stdout.write(modeIndicator + "\n\n");
// Calculate which entries are visible (last maxRows entries, reversed for display)
const startIndex = Math.max(0, entries.length - maxRows);
const visibleEntries = entries.slice(startIndex);
// 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);
const visibleReversed = [...visibleEntries].reverse();
// Map selected index to display index (for highlighting)
const selectedDisplayIndex = safeSelectedIndex >= startIndex
? safeSelectedIndex - startIndex
// Map selected index to display index (for highlighting in newest-first list)
const selectedDisplayIndex = safeSelectedIndex >= startIndex && safeSelectedIndex < startIndex + visibleEntries.length
? visibleEntries.length - 1 - (safeSelectedIndex - startIndex)
: -1;
// In wrap mode, calculate available width for message body

View File

@@ -667,7 +667,7 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
// ── Import module under test (after mocks) ──────────────────────────
const { runDashboard: runDashboardImpl } = await import("./dashboard.js");
const { runDashboard: runDashboardImpl, StreamedLogBuffer } = await import("./dashboard.js");
const { processPullRequestMergeTask, getMergeStrategy, getTaskBranchName } = await import("./task-lifecycle.js");
const dashboardDisposables: Array<() => void> = [];
@@ -1574,6 +1574,7 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
it("prints a warning when port fallback occurs", async () => {
const fallbackPort = 12345;
const serverEmitter = new EventEmitter();
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const mockServerListen = vi.fn((_port?: number) => {
process.nextTick(() => serverEmitter.emit("listening"));
@@ -1601,9 +1602,10 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
await new Promise((r) => setTimeout(r, 100));
// Should print warning with both the requested and actual ports
expect(consoleSpy).toHaveBeenCalledWith(
` Port 4040 in use, using ${fallbackPort} instead`,
expect(consoleWarnSpy).toHaveBeenCalledWith(
`[dashboard] Port 4040 in use, using ${fallbackPort} instead`,
);
consoleWarnSpy.mockRestore();
});
});
@@ -2498,3 +2500,118 @@ describe("promptForPort", () => {
removeListenerSpy.mockRestore();
});
});
describe("StreamedLogBuffer", () => {
afterEach(() => {
vi.useRealTimers();
});
it("coalesces partial chunks and flushes after idle timeout", () => {
vi.useFakeTimers();
const lines: string[] = [];
const buffer = new StreamedLogBuffer((line) => lines.push(line), 100);
buffer.push("Hel");
buffer.push("lo");
expect(lines).toEqual([]);
vi.advanceTimersByTime(100);
expect(lines).toEqual(["Hello"]);
});
it("flushes complete newline-delimited lines immediately", () => {
const lines: string[] = [];
const buffer = new StreamedLogBuffer((line) => lines.push(line), 100);
buffer.push("one\ntwo\n");
expect(lines).toEqual(["one", "two"]);
buffer.dispose();
});
});
describe("runDashboard — merge stream sink routing", () => {
it("routes streamed merge deltas through log sink without raw stdout writes", async () => {
const { TaskStore, AutomationStore, AgentStore, PluginStore, PluginLoader, CentralCore } = await import("@fusion/core");
const { aiMergeTask } = await import("@fusion/engine");
const { createServer } = await import("@fusion/dashboard");
const { AuthStorage, DefaultPackageManager, ModelRegistry, discoverAndLoadExtensions, createExtensionRuntime } = await import("@mariozechner/pi-coding-agent");
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
(AutomationStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
}));
(AgentStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
listAgents: vi.fn().mockResolvedValue([]),
on: vi.fn(),
off: vi.fn(),
}));
(PluginStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
off: vi.fn(),
}));
(PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
getPluginRoutes: vi.fn().mockReturnValue([]),
on: vi.fn(),
off: vi.fn(),
}));
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
listProjects: vi.fn().mockResolvedValue([{ id: "project-1", path: process.cwd() }]),
}));
(AuthStorage.create as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
getApiKey: vi.fn().mockResolvedValue(undefined),
getAuth: vi.fn(),
setAuth: vi.fn(),
});
(DefaultPackageManager as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
resolve: vi.fn().mockResolvedValue({ extensions: [] }),
}));
(ModelRegistry as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
registerProvider: vi.fn(),
refresh: vi.fn(),
}));
(discoverAndLoadExtensions as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
runtime: { pendingProviderRegistrations: [] },
errors: [],
});
(createExtensionRuntime as unknown as ReturnType<typeof vi.fn>).mockReturnValue({});
const stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
(aiMergeTask as ReturnType<typeof vi.fn>).mockImplementationOnce(
async (_store: unknown, _cwd: string, _taskId: string, opts: { onAgentText?: (delta: string) => void }) => {
opts.onAgentText?.("Hel");
opts.onAgentText?.("lo");
opts.onAgentText?.("\nWorld");
opts.onAgentText?.("!\nTail");
return { merged: true };
},
);
await runDashboard(0, { open: false, dev: true });
consoleLogSpy.mockClear();
stdoutWriteSpy.mockClear();
const createServerCall = (createServer as ReturnType<typeof vi.fn>).mock.calls[0];
const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise<unknown> };
await serverOpts.onMerge("FN-TEST");
expect(stdoutWriteSpy).not.toHaveBeenCalled();
expect(consoleLogSpy).toHaveBeenCalledWith("[merge] Hello");
expect(consoleLogSpy).toHaveBeenCalledWith("[merge] World!");
expect(consoleLogSpy).toHaveBeenCalledWith("[merge] Tail");
expect(consoleLogSpy).not.toHaveBeenCalledWith("[merge] H");
stdoutWriteSpy.mockRestore();
consoleLogSpy.mockRestore();
});
});

View File

@@ -26,6 +26,72 @@ let diagnosticStartTime = 0;
let diagnosticDbHealthCheck: (() => boolean) | null = null;
let diagnosticStoreListenerCheck: (() => Record<string, number>) | null = null;
const STREAM_LOG_FLUSH_IDLE_MS = 100;
export class StreamedLogBuffer {
private pending = "";
private flushTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
private readonly emitLine: (line: string) => void,
private readonly flushIdleMs: number = STREAM_LOG_FLUSH_IDLE_MS,
) {}
push(delta: string): void {
if (!delta) return;
this.pending += delta;
this.flushCompletedLines();
this.scheduleFlush();
}
flush(): void {
this.clearFlushTimer();
const trailing = this.pending.trim();
if (trailing.length > 0) {
this.emitLine(trailing);
}
this.pending = "";
}
dispose(): void {
this.clearFlushTimer();
this.pending = "";
}
private flushCompletedLines(): void {
if (!this.pending.includes("\n")) {
return;
}
const splitLines = this.pending.split(/\r?\n/);
const completeLines = splitLines.slice(0, -1);
this.pending = splitLines[splitLines.length - 1] ?? "";
for (const line of completeLines) {
const normalized = line.trim();
if (normalized.length > 0) {
this.emitLine(normalized);
}
}
}
private scheduleFlush(): void {
this.clearFlushTimer();
this.flushTimer = setTimeout(() => {
this.flush();
}, this.flushIdleMs);
this.flushTimer.unref?.();
}
private clearFlushTimer(): void {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
}
}
/**
* Format bytes to human-readable string
*/
@@ -438,7 +504,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
try {
if (!store) {
taskSummary = "tasks=unavailable (store not initialized)";
console.log(`[dashboard] shutdown requested reason=${reason} pid=${process.pid} ppid=${process.ppid} uptime=${uptimeSeconds}s ${taskSummary}`);
logSink.log(`shutdown requested reason=${reason} pid=${process.pid} ppid=${process.ppid} uptime=${uptimeSeconds}s ${taskSummary}`, "dashboard");
return;
}
const tasks = await store.listTasks({ slim: true, includeArchived: false });
@@ -457,8 +523,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
taskSummary = `tasks=unavailable (${message})`;
}
console.log(
`[dashboard] shutdown requested reason=${reason} pid=${process.pid} ppid=${process.ppid} uptime=${uptimeSeconds}s ${taskSummary}`,
logSink.log(
`shutdown requested reason=${reason} pid=${process.pid} ppid=${process.ppid} uptime=${uptimeSeconds}s ${taskSummary}`,
"dashboard",
);
}
@@ -559,11 +626,22 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// In non-dev mode: replaced by engine.onMerge() after ProjectEngine starts
// (semaphore-gated via the engine's InProcessRuntime).
//
const onMergeImpl = (taskId: string) =>
aiMergeTask(store, cwd, taskId, {
agentStore,
onAgentText: (delta) => process.stdout.write(delta),
});
const onMergeImpl = async (taskId: string) => {
const streamedMergeLog = new StreamedLogBuffer(
(line) => logSink.log(line, "merge"),
STREAM_LOG_FLUSH_IDLE_MS,
);
try {
return await aiMergeTask(store, cwd, taskId, {
agentStore,
onAgentText: (delta) => streamedMergeLog.push(delta),
});
} finally {
streamedMergeLog.flush();
streamedMergeLog.dispose();
}
};
const onMerge = (taskId: string) => onMergeImpl(taskId);
@@ -790,7 +868,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
peerExchangeService.start();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to start peer exchange service: ${message}`);
logSink.warn(`Failed to start peer exchange service: ${message}`, "dashboard");
}
// Use the same CentralCore instance for mesh operations
@@ -858,7 +936,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => `${type}:${count}`)
.join(", ");
console.log(`[dashboard] active handles at shutdown: ${handleSummary}`);
logSink.log(`active handles at shutdown: ${handleSummary}`, "dashboard");
} catch {
// Ignore errors getting handle types
}
@@ -876,7 +954,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
await peerExchangeService.stop();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to stop peer exchange service: ${message}`);
logSink.warn(`Failed to stop peer exchange service: ${message}`, "dashboard");
}
}
@@ -886,13 +964,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
centralCoreForMesh.stopDiscovery();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to stop mDNS discovery: ${message}`);
logSink.warn(`Failed to stop mDNS discovery: ${message}`, "dashboard");
}
try {
await centralCoreForMesh.updateNode(localNodeIdForMesh, { status: "offline" });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to set local node offline: ${message}`);
logSink.warn(`Failed to set local node offline: ${message}`, "dashboard");
}
}
@@ -909,7 +987,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// the process silently — the exit handler tries to log to the now-dead
// PTY and the write is lost.
registerHandler(process, "SIGHUP", () => {
console.log("[dashboard] Received SIGHUP (terminal disconnected) — ignoring");
logSink.log("Received SIGHUP (terminal disconnected) — ignoring", "dashboard");
});
} else {
// Dev mode: create HeartbeatMonitor + TriggerScheduler inline (engine not started)
@@ -927,7 +1005,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
peerExchangeService.start();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to initialize mesh networking: ${message}`);
logSink.warn(`Failed to initialize mesh networking: ${message}`, "dashboard");
}
try {
@@ -1048,7 +1126,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => `${type}:${count}`)
.join(", ");
console.log(`[dashboard] active handles at shutdown: ${handleSummary}`);
logSink.log(`active handles at shutdown: ${handleSummary}`, "dashboard");
} catch {
// Ignore errors getting handle types
}
@@ -1065,7 +1143,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
await peerExchangeService.stop();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to stop peer exchange service: ${message}`);
logSink.warn(`Failed to stop peer exchange service: ${message}`, "dashboard");
}
}
@@ -1075,13 +1153,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
centralCoreForMesh.stopDiscovery();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to stop mDNS discovery: ${message}`);
logSink.warn(`Failed to stop mDNS discovery: ${message}`, "dashboard");
}
try {
await centralCoreForMesh.updateNode(localNodeIdForMesh, { status: "offline" });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to set local node offline: ${message}`);
logSink.warn(`Failed to set local node offline: ${message}`, "dashboard");
}
}
@@ -1097,7 +1175,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Ignore SIGHUP so the dashboard survives SSH session disconnects
registerHandler(process, "SIGHUP", () => {
console.log("[dashboard] Received SIGHUP (terminal disconnected) — ignoring");
logSink.log("Received SIGHUP (terminal disconnected) — ignoring", "dashboard");
});
}
@@ -1107,7 +1185,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (err.code === "EADDRINUSE") {
server.listen(0, selectedHost);
} else {
console.error(`Failed to start server: ${err.message}`);
logSink.error(`Failed to start server: ${err.message}`, "dashboard");
process.exit(1);
}
});
@@ -1116,7 +1194,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const actualPort = (server.address() as AddressInfo).port;
if (actualPort !== selectedPort) {
console.log(`Port ${selectedPort} in use, using ${actualPort} instead`);
logSink.warn(`Port ${selectedPort} in use, using ${actualPort} instead`, "dashboard");
}
// ── mDNS discovery: broadcast presence and listen for other nodes ───────
@@ -1135,7 +1213,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to start mDNS discovery: ${message}`);
logSink.warn(`Failed to start mDNS discovery: ${message}`, "dashboard");
}
}
@@ -1153,7 +1231,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to set local node online: ${message}`);
logSink.warn(`Failed to set local node online: ${message}`, "dashboard");
}
}