Skip to content
45 changes: 45 additions & 0 deletions src/process-output-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test";
import { aggregateProcessOutput, formatProcessOutputTitle } from "./process-output-scope";

describe("aggregateProcessOutput", () => {
test("sorts by timestamp and uses source order as a tie-breaker", () => {
expect(
aggregateProcessOutput([
{
name: "api",
entries: [
{ timestamp: "2026-06-09T00:00:02.000Z", stream: "stdout", line: "second" },
{ timestamp: "2026-06-09T00:00:01.000Z", stream: "stdout", line: "api first" },
],
},
{
name: "worker",
entries: [
{ timestamp: "2026-06-09T00:00:01.000Z", stream: "stderr", line: "worker first" },
],
},
]).map((entry) => entry.line),
).toEqual(["[api] api first", "[worker] worker first", "[api] second"]);
});

test("preserves per-source order for identical timestamps", () => {
expect(
aggregateProcessOutput([
{
name: "api",
entries: [
{ timestamp: "2026-06-09T00:00:01.000Z", stream: "stdout", line: "one" },
{ timestamp: "2026-06-09T00:00:01.000Z", stream: "stdout", line: "two" },
],
},
]).map((entry) => entry.line),
).toEqual(["[api] one", "[api] two"]);
});
});

describe("formatProcessOutputTitle", () => {
test("formats aggregate and scoped titles", () => {
expect(formatProcessOutputTitle(null)).toBe("Logs");
expect(formatProcessOutputTitle("api")).toBe("Logs (api)");
});
});
29 changes: 29 additions & 0 deletions src/process-output-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { LogEntry } from "./types";

export interface ProcessOutputSource {
name: string;
entries: LogEntry[];
}

export const aggregateProcessOutput = (sources: ProcessOutputSource[]): LogEntry[] =>
sources
.flatMap((source, sourceIndex) =>
source.entries.map((entry, entryIndex) => ({
entry: {
...entry,
line: `[${source.name}] ${entry.line}`,
},
sourceIndex,
entryIndex,
})),
)
.sort((left, right) => {
const timestampOrder = left.entry.timestamp.localeCompare(right.entry.timestamp);
if (timestampOrder !== 0) return timestampOrder;
if (left.sourceIndex !== right.sourceIndex) return left.sourceIndex - right.sourceIndex;
return left.entryIndex - right.entryIndex;
})
.map((item) => item.entry);

export const formatProcessOutputTitle = (name: string | null): string =>
name ? `Logs (${name})` : "Logs";
15 changes: 5 additions & 10 deletions src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
formatDuration,
type ProcessMetricsSample,
} from "./process-metrics";
import { aggregateProcessOutput, formatProcessOutputTitle } from "./process-output-scope";
import { getRuntimeStatusView } from "./runtime-status";
import type { ServiceManager, ServiceView } from "./service-manager";
import { formatCommandSpec } from "./shared";
Expand Down Expand Up @@ -1500,15 +1501,9 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo
};
}

const entries = manager
.getViews()
.flatMap((view) =>
view.log.all().map((entry) => ({
...entry,
line: `[${view.name}] ${entry.line}`,
})),
)
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
const entries = aggregateProcessOutput(
manager.getViews().map((view) => ({ name: view.name, entries: view.log.all() })),
);

return {
entries,
Expand Down Expand Up @@ -2081,7 +2076,7 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo
logSource === "external" && externalRuntimeManager
? externalRuntimeManager.getSelectedService()?.name
: manager.getSelectedView()?.name;
logPanelTitle.content = selectedLogName ? `Logs (${selectedLogName})` : "Logs";
logPanelTitle.content = formatProcessOutputTitle(selectedLogName ?? null);
logPanelTitle.fg = panelTitleColor("logs");
const logsBackground = panelBackgroundColor("logs");
servicePanel.backgroundColor = logsBackground;
Expand Down
Loading