Skip to content

Commit 83242c9

Browse files
committed
refactor: add rendering engine, render session, and output formatting
1 parent 4cc895d commit 83242c9

File tree

12 files changed

+1788
-194
lines changed

12 files changed

+1788
-194
lines changed

src/rendering/render.ts

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import type {
2+
CompilerErrorEvent,
3+
CompilerWarningEvent,
4+
PipelineEvent,
5+
TestFailureEvent,
6+
} from '../types/pipeline-events.ts';
7+
import { sessionStore } from '../utils/session-store.ts';
8+
import { deriveDiagnosticBaseDir } from '../utils/renderers/index.ts';
9+
import {
10+
formatBuildStageEvent,
11+
formatDetailTreeEvent,
12+
formatFileRefEvent,
13+
formatGroupedCompilerErrors,
14+
formatGroupedTestFailures,
15+
formatGroupedWarnings,
16+
formatHeaderEvent,
17+
formatNextStepsEvent,
18+
formatSectionEvent,
19+
formatStatusLineEvent,
20+
formatSummaryEvent,
21+
formatTableEvent,
22+
formatTestDiscoveryEvent,
23+
} from '../utils/renderers/event-formatting.ts';
24+
import { createCliTextRenderer } from '../utils/renderers/cli-text-renderer.ts';
25+
import type { RenderSession, RenderStrategy, ImageAttachment } from './types.ts';
26+
27+
function isErrorEvent(event: PipelineEvent): boolean {
28+
return (
29+
(event.type === 'status-line' && event.level === 'error') ||
30+
(event.type === 'summary' && event.status === 'FAILED')
31+
);
32+
}
33+
34+
function createTextRenderSession(): RenderSession {
35+
const events: PipelineEvent[] = [];
36+
const attachments: ImageAttachment[] = [];
37+
const contentParts: string[] = [];
38+
const suppressWarnings = sessionStore.get('suppressWarnings');
39+
const groupedCompilerErrors: CompilerErrorEvent[] = [];
40+
const groupedWarnings: CompilerWarningEvent[] = [];
41+
const groupedTestFailures: TestFailureEvent[] = [];
42+
43+
let diagnosticBaseDir: string | null = null;
44+
let hasError = false;
45+
46+
const pushText = (text: string): void => {
47+
contentParts.push(text);
48+
};
49+
50+
const pushSection = (text: string): void => {
51+
pushText(`\n${text}`);
52+
};
53+
54+
return {
55+
emit(event: PipelineEvent): void {
56+
events.push(event);
57+
if (isErrorEvent(event)) hasError = true;
58+
59+
switch (event.type) {
60+
case 'header': {
61+
diagnosticBaseDir = deriveDiagnosticBaseDir(event);
62+
pushSection(formatHeaderEvent(event));
63+
break;
64+
}
65+
66+
case 'build-stage': {
67+
pushSection(formatBuildStageEvent(event));
68+
break;
69+
}
70+
71+
case 'status-line': {
72+
pushSection(formatStatusLineEvent(event));
73+
break;
74+
}
75+
76+
case 'section': {
77+
pushText(`\n\n${formatSectionEvent(event)}`);
78+
break;
79+
}
80+
81+
case 'detail-tree': {
82+
pushSection(formatDetailTreeEvent(event));
83+
break;
84+
}
85+
86+
case 'table': {
87+
pushSection(formatTableEvent(event));
88+
break;
89+
}
90+
91+
case 'file-ref': {
92+
pushSection(formatFileRefEvent(event));
93+
break;
94+
}
95+
96+
case 'compiler-warning': {
97+
if (!suppressWarnings) {
98+
groupedWarnings.push(event);
99+
}
100+
break;
101+
}
102+
103+
case 'compiler-error': {
104+
groupedCompilerErrors.push(event);
105+
break;
106+
}
107+
108+
case 'test-discovery': {
109+
pushText(formatTestDiscoveryEvent(event));
110+
break;
111+
}
112+
113+
case 'test-progress': {
114+
break;
115+
}
116+
117+
case 'test-failure': {
118+
groupedTestFailures.push(event);
119+
break;
120+
}
121+
122+
case 'summary': {
123+
const diagOpts = { baseDir: diagnosticBaseDir ?? undefined };
124+
const diagnosticSections: string[] = [];
125+
126+
if (groupedTestFailures.length > 0) {
127+
diagnosticSections.push(formatGroupedTestFailures(groupedTestFailures, diagOpts));
128+
groupedTestFailures.length = 0;
129+
}
130+
131+
if (groupedWarnings.length > 0) {
132+
diagnosticSections.push(formatGroupedWarnings(groupedWarnings, diagOpts));
133+
groupedWarnings.length = 0;
134+
}
135+
136+
if (event.status === 'FAILED' && groupedCompilerErrors.length > 0) {
137+
diagnosticSections.push(formatGroupedCompilerErrors(groupedCompilerErrors, diagOpts));
138+
groupedCompilerErrors.length = 0;
139+
}
140+
141+
if (diagnosticSections.length > 0) {
142+
pushSection(diagnosticSections.join('\n\n'));
143+
}
144+
145+
pushSection(formatSummaryEvent(event));
146+
break;
147+
}
148+
149+
case 'next-steps': {
150+
const effectiveRuntime = event.runtime === 'cli' ? 'cli' : 'mcp';
151+
pushText(`\n\n${formatNextStepsEvent(event, effectiveRuntime)}`);
152+
break;
153+
}
154+
}
155+
},
156+
157+
attach(image: ImageAttachment): void {
158+
attachments.push(image);
159+
},
160+
161+
getEvents(): readonly PipelineEvent[] {
162+
return events;
163+
},
164+
165+
getAttachments(): readonly ImageAttachment[] {
166+
return attachments;
167+
},
168+
169+
isError(): boolean {
170+
return hasError;
171+
},
172+
173+
finalize(): string {
174+
diagnosticBaseDir = null;
175+
return contentParts.join('');
176+
},
177+
};
178+
}
179+
180+
function createCliTextRenderSession(options: { interactive: boolean }): RenderSession {
181+
const events: PipelineEvent[] = [];
182+
const attachments: ImageAttachment[] = [];
183+
const renderer = createCliTextRenderer(options);
184+
let hasError = false;
185+
186+
return {
187+
emit(event: PipelineEvent): void {
188+
events.push(event);
189+
if (isErrorEvent(event)) hasError = true;
190+
renderer.onEvent(event);
191+
},
192+
193+
attach(image: ImageAttachment): void {
194+
attachments.push(image);
195+
},
196+
197+
getEvents(): readonly PipelineEvent[] {
198+
return events;
199+
},
200+
201+
getAttachments(): readonly ImageAttachment[] {
202+
return attachments;
203+
},
204+
205+
isError(): boolean {
206+
return hasError;
207+
},
208+
209+
finalize(): string {
210+
renderer.finalize();
211+
return '';
212+
},
213+
};
214+
}
215+
216+
function createCliJsonRenderSession(): RenderSession {
217+
const events: PipelineEvent[] = [];
218+
const attachments: ImageAttachment[] = [];
219+
let hasError = false;
220+
221+
return {
222+
emit(event: PipelineEvent): void {
223+
events.push(event);
224+
if (isErrorEvent(event)) hasError = true;
225+
process.stdout.write(JSON.stringify(event) + '\n');
226+
},
227+
228+
attach(image: ImageAttachment): void {
229+
attachments.push(image);
230+
},
231+
232+
getEvents(): readonly PipelineEvent[] {
233+
return events;
234+
},
235+
236+
getAttachments(): readonly ImageAttachment[] {
237+
return attachments;
238+
},
239+
240+
isError(): boolean {
241+
return hasError;
242+
},
243+
244+
finalize(): string {
245+
return '';
246+
},
247+
};
248+
}
249+
250+
export interface RenderSessionOptions {
251+
interactive?: boolean;
252+
}
253+
254+
export function createRenderSession(
255+
strategy: RenderStrategy,
256+
options?: RenderSessionOptions,
257+
): RenderSession {
258+
switch (strategy) {
259+
case 'text':
260+
return createTextRenderSession();
261+
case 'cli-text':
262+
return createCliTextRenderSession({ interactive: options?.interactive ?? false });
263+
case 'cli-json':
264+
return createCliJsonRenderSession();
265+
}
266+
}
267+
268+
export function renderEvents(events: readonly PipelineEvent[], strategy: RenderStrategy): string {
269+
const session = createRenderSession(strategy);
270+
for (const event of events) {
271+
session.emit(event);
272+
}
273+
return session.finalize();
274+
}

src/rendering/types.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { PipelineEvent } from '../types/pipeline-events.ts';
2+
import type { NextStep, NextStepParamsMap } from '../types/common.ts';
3+
4+
export type RenderStrategy = 'text' | 'cli-text' | 'cli-json';
5+
6+
export interface ImageAttachment {
7+
data: string;
8+
mimeType: string;
9+
}
10+
11+
export interface RenderSession {
12+
emit(event: PipelineEvent): void;
13+
attach(image: ImageAttachment): void;
14+
getEvents(): readonly PipelineEvent[];
15+
getAttachments(): readonly ImageAttachment[];
16+
isError(): boolean;
17+
finalize(): string;
18+
}
19+
20+
export interface ToolHandlerContext {
21+
emit: (event: PipelineEvent) => void;
22+
attach: (image: ImageAttachment) => void;
23+
nextStepParams?: NextStepParamsMap;
24+
nextSteps?: NextStep[];
25+
}

src/utils/cli-progress-reporter.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import * as clack from '@clack/prompts';
2+
3+
export interface CliProgressReporter {
4+
update(message: string): void;
5+
clear(): void;
6+
}
7+
8+
export function createCliProgressReporter(): CliProgressReporter {
9+
const spinner = clack.spinner();
10+
let active = false;
11+
12+
return {
13+
update(message: string): void {
14+
if (!active) {
15+
spinner.start(message);
16+
active = true;
17+
return;
18+
}
19+
20+
spinner.message(message);
21+
},
22+
clear(): void {
23+
if (!active) {
24+
return;
25+
}
26+
27+
spinner.clear();
28+
active = false;
29+
},
30+
};
31+
}

0 commit comments

Comments
 (0)