Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/failed-summary-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@aliou/pi-processes": patch
---

Fold failed and killed processes into `! N failed` and `■ N killed` summary tokens in the status widget, mirroring the existing `✓ N done` token. A new failure stays individual until the next process starts, then folds into the summaries.
110 changes: 109 additions & 1 deletion extensions/processes-dock/widget/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function defaultConfig(): ProcessProtocolConfig {
},
follow: { enabledByDefault: true, autoHideOnFinish: true },
widget: {
showStatusWidget: false,
showStatusWidget: true,
dockDefaultState: "collapsed",
dockHeight: 10,
},
Expand All @@ -59,6 +59,7 @@ function defaultConfig(): ProcessProtocolConfig {

interface Harness {
widgetState: Map<string, "visible" | "hidden">;
widgetContent: Map<string, unknown>;
emitStarted: (info: ProcessInfo) => void;
emitEnded: (info: ProcessInfo) => void;
emitPin: (id: string | null) => CommandPinResult;
Expand All @@ -68,13 +69,15 @@ interface Harness {
function createHarness(): Harness {
const events: EventBus = createEventBus();
const widgetState = new Map<string, "visible" | "hidden">();
const widgetContent = new Map<string, unknown>();

let processList: ProcessInfo[] = [];
const config = defaultConfig();

const ui: ExtensionUIContext = {
setWidget: ((key: string, content: unknown, _options?: unknown) => {
widgetState.set(key, content === undefined ? "hidden" : "visible");
widgetContent.set(key, content);
}) as never,
} as unknown as ExtensionUIContext;

Expand Down Expand Up @@ -115,6 +118,7 @@ function createHarness(): Harness {

return {
widgetState,
widgetContent,
emitStarted: (info: ProcessInfo) => {
upsertProcess(info);
events.emit(CHANNELS.STARTED, info);
Expand All @@ -141,11 +145,27 @@ function createHarness(): Harness {
}

const DOCK_KEY = "processes-dock";
const STATUS_KEY = "processes-status";

function dockIsVisible(h: Harness): boolean {
return h.widgetState.get(DOCK_KEY) === "visible";
}

function statusLine(h: Harness): string | null {
const factory = h.widgetContent.get(STATUS_KEY) as
| ((
tui: unknown,
theme: unknown,
) => { render: (width: number) => string[] })
| undefined;
if (!factory) return null;
const theme = {
fg: (color: string, text: string) => `{${color}:${text}}`,
bg: (_color: string, text: string) => text,
};
return factory(null, theme).render(200)[0] ?? null;
}

describe("dock auto-close", () => {
beforeEach(() => {
vi.useFakeTimers();
Expand Down Expand Up @@ -257,3 +277,91 @@ describe("dock auto-close", () => {
}
});
});

describe("status widget pending terminal processes", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});

it("keeps a failed process visible until the next process starts, then folds it", () => {
const h = createHarness();
try {
h.emitStarted(makeProcess({ id: "proc_1", name: "lint" }));
h.emitEnded(
makeProcess({
id: "proc_1",
name: "lint",
status: "exited",
endTime: 2000,
exitCode: 7,
success: false,
}),
);
vi.advanceTimersByTime(130);
expect(statusLine(h)).toContain("{error:lint}");

h.emitStarted(
makeProcess({ id: "proc_2", name: "dev", startTime: 3000 }),
);
vi.advanceTimersByTime(130);
expect(statusLine(h)).toContain("{error:!}");
expect(statusLine(h)).toContain("1 failed");
expect(statusLine(h)).not.toContain("lint");
} finally {
h.dispose();
}
});

it("keeps a killed process visible until the next process starts, then folds it", () => {
const h = createHarness();
try {
h.emitStarted(makeProcess({ id: "proc_1", name: "server" }));
h.emitEnded(
makeProcess({
id: "proc_1",
name: "server",
status: "killed",
endTime: 2000,
success: false,
}),
);
vi.advanceTimersByTime(130);
expect(statusLine(h)).toContain("server");

h.emitStarted(
makeProcess({ id: "proc_2", name: "dev", startTime: 3000 }),
);
vi.advanceTimersByTime(130);
expect(statusLine(h)).toContain("{dim:■}");
expect(statusLine(h)).toContain("1 killed");
expect(statusLine(h)).not.toContain("server");
} finally {
h.dispose();
}
});

it("folds a successful exit into the done summary immediately", () => {
const h = createHarness();
try {
h.emitStarted(makeProcess({ id: "proc_1", name: "build" }));
h.emitEnded(
makeProcess({
id: "proc_1",
name: "build",
status: "exited",
endTime: 2000,
exitCode: 0,
success: true,
}),
);
vi.advanceTimersByTime(130);
expect(statusLine(h)).toContain("1 done");
expect(statusLine(h)).not.toContain("build");
} finally {
h.dispose();
}
});
});
23 changes: 21 additions & 2 deletions extensions/processes-dock/widget/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { buildDroppedOutputLine, trimToBudget } from "../../shared/line-buffer";
import {
CHANNELS,
type CommandPinPayload,
type ProcessesEndedPayload,
type ProcessesOutputChangedPayload,
type ProcessProtocolNotificationPayload,
} from "../../shared/protocol";
Expand Down Expand Up @@ -63,6 +64,8 @@ export function setupDockWidgets(
let processLogStream: Array<{ processId: string; line: ProcessLogLine }> = [];
let pinnedConnection: LogsConnection | null = null;
let pinnedConnectionId: string | null = null;
const pendingTerminalIds = new Set<string>();

let hasSeenRunningProcess = false;
let pendingRefresh: NodeJS.Timeout | null = null;
let pendingRender: NodeJS.Timeout | null = null;
Expand Down Expand Up @@ -148,7 +151,8 @@ export function setupDockWidgets(
ctx.ui.setWidget(
STATUS_WIDGET_KEY,
(_tui, theme: Theme) => ({
render: (width: number) => renderStatusWidget(processes, theme, width),
render: (width: number) =>
renderStatusWidget(processes, theme, width, pendingTerminalIds),
invalidate: () => undefined,
}),
{ placement: "belowEditor" },
Expand All @@ -165,6 +169,9 @@ export function setupDockWidgets(
for (const id of notifyMarkers.keys()) {
if (!liveIds.has(id)) notifyMarkers.delete(id);
}
for (const id of pendingTerminalIds) {
if (!liveIds.has(id)) pendingTerminalIds.delete(id);
}
if (processes.some((process) => process.status === "running")) {
hasSeenRunningProcess = true;
}
Expand Down Expand Up @@ -344,13 +351,25 @@ export function setupDockWidgets(
// Set synchronously so a process that exits within the scheduleRefresh
// throttle window still counts as "seen running" for auto-close.
hasSeenRunningProcess = true;
pendingTerminalIds.clear();
if (config.widget.dockDefaultState === "expanded") state.actions.expand();
else if (config.widget.dockDefaultState === "collapsed") {
state.actions.collapse();
}
scheduleRefresh();
};

const isTerminalFailure = (process: ProcessInfo): boolean => {
if (LIVE_STATUSES.has(process.status)) return false;
return !(process.status === "exited" && process.success);
};

const handleEnded = (rawPayload: unknown) => {
const process = rawPayload as ProcessesEndedPayload;
if (isTerminalFailure(process)) pendingTerminalIds.add(process.id);
scheduleRefresh();
};

const handleOutputChanged = (rawPayload: unknown) => {
const payload = rawPayload as ProcessesOutputChangedPayload;
if (
Expand Down Expand Up @@ -416,7 +435,7 @@ export function setupDockWidgets(
};

disposers.push(events.on(CHANNELS.STARTED, handleStarted));
disposers.push(events.on(CHANNELS.ENDED, scheduleRefresh));
disposers.push(events.on(CHANNELS.ENDED, handleEnded));
disposers.push(events.on(CHANNELS.CHANGED, scheduleRefresh));
disposers.push(events.on(CHANNELS.OUTPUT_CHANGED, handleOutputChanged));
disposers.push(
Expand Down
110 changes: 109 additions & 1 deletion extensions/processes-dock/widget/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe("renderStatusWidget", () => {
expect(lines[0]).toContain("1 done");
});

it("shows failed processes individually, not in the summary", () => {
it("keeps a failed process visible by name while its id is pending", () => {
const lines = renderStatusWidget(
[
makeProcess({
Expand All @@ -110,6 +110,8 @@ describe("renderStatusWidget", () => {
}),
],
theme,
200,
new Set(["proc_2"]),
);
expect(lines).toHaveLength(1);
// Failed process shown individually with error glyph and name.
Expand All @@ -119,6 +121,108 @@ describe("renderStatusWidget", () => {
expect(lines[0]).not.toContain("done");
});

it("folds failed processes into a summary token once no longer pending", () => {
const lines = renderStatusWidget(
[
makeProcess({
id: "proc_1",
status: "running",
name: "dev",
}),
makeProcess({
id: "proc_2",
status: "exited",
success: false,
exitCode: 7,
endTime: 2000,
name: "lint",
}),
makeProcess({
id: "proc_3",
status: "exited",
success: false,
exitCode: 1,
endTime: 1500,
name: "typecheck",
}),
],
theme,
);
expect(lines).toHaveLength(1);
expect(lines[0]).not.toContain("{error:lint}");
expect(lines[0]).not.toContain("{error:typecheck}");
expect(lines[0]).toContain("{error:!}");
expect(lines[0]).toContain("2 failed");
});

it("folds killed processes into a square-glyph summary token", () => {
const lines = renderStatusWidget(
[
makeProcess({
id: "proc_1",
status: "killed",
success: false,
endTime: 2000,
name: "server",
}),
makeProcess({
id: "proc_2",
status: "killed",
success: false,
endTime: 1500,
name: "worker",
}),
],
theme,
);
expect(lines[0]).not.toContain("server");
expect(lines[0]).not.toContain("worker");
expect(lines[0]).toContain("{dim:■}");
expect(lines[0]).toContain("2 killed");
});

it("orders summaries after individuals: failed, killed, then done", () => {
const lines = renderStatusWidget(
[
makeProcess({
id: "proc_1",
status: "running",
name: "dev",
}),
makeProcess({
id: "proc_2",
status: "exited",
success: false,
exitCode: 7,
endTime: 2000,
name: "lint",
}),
makeProcess({
id: "proc_3",
status: "killed",
success: false,
endTime: 1900,
name: "server",
}),
makeProcess({
id: "proc_4",
status: "exited",
success: true,
exitCode: 0,
endTime: 1800,
name: "build",
}),
],
theme,
);
const line = lines[0] as string;
const order = ["{accent:dev}", "1 failed", "1 killed", "1 done"].map(
(token) => line.indexOf(token),
);
expect(order.every((index) => index >= 0)).toBe(true);
expect(order).toEqual([...order].sort((a, b) => a - b));
});

it("renders a failed exit with an error-toned name", () => {
const lines = renderStatusWidget(
[
Expand All @@ -130,6 +234,8 @@ describe("renderStatusWidget", () => {
}),
],
theme,
200,
new Set(["proc_1"]),
);
expect(lines[0]).toContain("{error:dev}");
});
Expand All @@ -148,6 +254,8 @@ describe("renderStatusWidget", () => {
}),
],
theme,
200,
new Set(["proc_2"]),
);
expect(lines).toHaveLength(1);
expect(lines[0]).toContain("{dim: }");
Expand Down
Loading
Loading