@@ -377,12 +377,37 @@ export interface StreamRenderer {
377377 end ( ) : void ;
378378}
379379
380+ // A line-oriented output target handed to StreamEventRenderers. `out` writes
381+ // inline (and may leave the cursor mid-line, e.g. incremental answer text);
382+ // `line` writes a full line. The core tracks the mid-line state so `end` can
383+ // flush a trailing newline before any end-of-stream content.
384+ export interface StreamSink {
385+ out ( text : string ) : void ;
386+ line ( text : string ) : void ;
387+ }
388+
389+ // Strategy for rendering ONE family of events in pretty mode. The core renderer
390+ // owns all the generic mechanics (machine-format serialization, the sink and its
391+ // mid-line tracking, calling `end`); a StreamEventRenderer only has to know how to
392+ // turn its own event schema into text. New/changed event schemas mean a new
393+ // strategy passed at the call site — the core never changes. See generate-commands.ts,
394+ // which selects the strategy per endpoint from the response's event-stream schema.
395+ export interface StreamEventRenderer {
396+ write ( event : unknown , sink : StreamSink ) : void ;
397+ // Flush end-of-stream content (e.g. citations). Optional; defaults to a no-op.
398+ end ?( sink : StreamSink ) : void ;
399+ }
400+
380401// Machine formats stream one record per event so a consumer can parse the output
381402// incrementally: NDJSON for --json (one compact object per line), a stream of
382- // YAML documents for --yaml (each introduced by the `---` marker). Pretty mode
383- // renders progressively (see createPrettyStreamRenderer). Format resolution — and
384- // the piped-defaults-to-YAML behaviour — is shared with printResult.
385- export function createStreamRenderer ( options : OutputOptions ) : StreamRenderer {
403+ // YAML documents for --yaml (each introduced by the `---` marker) — both fully
404+ // schema-agnostic. Pretty mode delegates per-event rendering to `eventRenderer`
405+ // (a generic fallback when the caller doesn't supply one). Format resolution —
406+ // and the piped-defaults-to-YAML behaviour — is shared with printResult.
407+ export function createStreamRenderer (
408+ options : OutputOptions ,
409+ eventRenderer : StreamEventRenderer = createGenericStreamRenderer ( ) ,
410+ ) : StreamRenderer {
386411 const format = resolveFormat ( options ) ;
387412 if ( format === 'json' ) {
388413 return {
@@ -397,89 +422,110 @@ export function createStreamRenderer(options: OutputOptions): StreamRenderer {
397422 end : ( ) => { } ,
398423 } ;
399424 }
400- return createPrettyStreamRenderer ( ) ;
401- }
402-
403- // Human-readable streaming. The three event shapes these endpoints emit are each
404- // rendered on the fly:
405- // - recommended-question events (`{ question }`) → one bullet per question
406- // - answer snapshots (`{ type: 'answer', answer }`) → the answer text, printed
407- // incrementally as the snapshot grows, then its sources as citations at end
408- // - agent-response events (`{ type: 'response_*' }`) → a concise status line
409- // Unknown shapes fall back to a compact block so nothing is silently dropped.
410- function createPrettyStreamRenderer ( ) : StreamRenderer {
411- let printedAnswerLen = 0 ; // chars of the (string) answer already written
425+
412426 let midLine = false ; // last write left the cursor mid-line
413- let sources : unknown [ ] | null = null ;
414- let followups : unknown [ ] | null = null ;
427+ const sink : StreamSink = {
428+ out ( text ) {
429+ if ( text . length === 0 ) return ;
430+ process . stdout . write ( text ) ;
431+ midLine = ! text . endsWith ( '\n' ) ;
432+ } ,
433+ line ( text ) {
434+ process . stdout . write ( `${ text } \n` ) ;
435+ midLine = false ;
436+ } ,
437+ } ;
438+ return {
439+ write : ( event ) => eventRenderer . write ( event , sink ) ,
440+ end : ( ) => {
441+ if ( midLine ) {
442+ process . stdout . write ( '\n' ) ;
443+ midLine = false ;
444+ }
445+ eventRenderer . end ?.( sink ) ;
446+ } ,
447+ } ;
448+ }
415449
416- const out = ( s : string ) => {
417- if ( s . length === 0 ) return ;
418- process . stdout . write ( s ) ;
419- midLine = ! s . endsWith ( '\n' ) ;
450+ // Fallback strategy: no schema knowledge. Strings stream inline; objects/scalars
451+ // print as a compact block. Used for any streaming endpoint without a dedicated
452+ // renderer, so a new SSE endpoint is never silently blank.
453+ export function createGenericStreamRenderer ( ) : StreamEventRenderer {
454+ return {
455+ write ( event , sink ) {
456+ if ( typeof event === 'string' ) sink . out ( event ) ;
457+ else if ( isPlainObject ( event ) ) sink . line ( formatPretty ( event ) ) ;
458+ else sink . line ( formatScalar ( event ) ) ;
459+ } ,
420460 } ;
421- const line = ( s : string ) => {
422- process . stdout . write ( `${ s } \n` ) ;
423- midLine = false ;
461+ }
462+
463+ // `SearchAIRecommendedQuestionStream`: one `{ question }` per event → a bullet.
464+ export function createQuestionStreamRenderer ( ) : StreamEventRenderer {
465+ return {
466+ write ( event , sink ) {
467+ if ( isPlainObject ( event ) && typeof event . question === 'string' ) {
468+ sink . line ( `• ${ event . question } ` ) ;
469+ }
470+ } ,
424471 } ;
472+ }
425473
474+ // `SearchAIAnswerStream`: progressive `{ type: 'answer', answer }` snapshots. Each
475+ // event carries the answer so far, so only the new suffix is printed; the latest
476+ // snapshot's sources/follow-ups render as citations once the stream ends.
477+ export function createAnswerStreamRenderer ( ) : StreamEventRenderer {
478+ let printedAnswerLen = 0 ;
479+ let sources : unknown [ ] | null = null ;
480+ let followups : unknown [ ] | null = null ;
426481 return {
427- write ( event : unknown ) : void {
428- if ( typeof event === 'string' ) {
429- out ( event ) ;
482+ write ( event , sink ) {
483+ if ( ! isPlainObject ( event ) || event . type !== 'answer' || ! isPlainObject ( event . answer ) ) {
430484 return ;
431485 }
432- if ( ! isPlainObject ( event ) ) {
433- line ( formatScalar ( event ) ) ;
434- return ;
486+ const answer = event . answer ;
487+ if ( Array . isArray ( answer . sources ) ) sources = answer . sources ;
488+ if ( Array . isArray ( answer . followupQuestions ) ) followups = answer . followupQuestions ;
489+ const text = streamAnswerText ( answer . answer ) ;
490+ if ( text !== undefined ) {
491+ if ( text . length >= printedAnswerLen ) sink . out ( text . slice ( printedAnswerLen ) ) ;
492+ else sink . out ( `\n${ text } ` ) ; // shrank unexpectedly: reprint whole
493+ printedAnswerLen = text . length ;
435494 }
436- // Recommended-question stream: one `{ question }` per event.
437- if ( typeof event . question === 'string' && event . type === undefined ) {
438- line ( `• ${ event . question } ` ) ;
495+ } ,
496+ end ( sink ) {
497+ if ( sources && sources . length > 0 ) {
498+ sink . line ( '' ) ;
499+ sink . line ( 'Sources:' ) ;
500+ for ( const s of sources ) sink . line ( ` - ${ formatStreamSource ( s ) } ` ) ;
501+ }
502+ if ( followups && followups . length > 0 ) {
503+ sink . line ( '' ) ;
504+ sink . line ( 'Follow-up questions:' ) ;
505+ for ( const q of followups ) sink . line ( ` - ${ formatScalar ( q ) } ` ) ;
506+ }
507+ } ,
508+ } ;
509+ }
510+
511+ // `AIStreamResponse`: a status line per event, except the JSON-object chunks,
512+ // which are raw text to concatenate.
513+ export function createAgentResponseStreamRenderer ( ) : StreamEventRenderer {
514+ return {
515+ write ( event , sink ) {
516+ if ( ! isPlainObject ( event ) ) {
517+ sink . line ( formatScalar ( event ) ) ;
439518 return ;
440519 }
441- // Answer stream: progressive `{ type: 'answer', answer }` snapshots.
442- // Each event carries the answer so far, so we print only the new
443- // suffix; sources/followups from the latest snapshot render at end.
444- if ( event . type === 'answer' && isPlainObject ( event . answer ) ) {
445- const answer = event . answer ;
446- if ( Array . isArray ( answer . sources ) ) sources = answer . sources ;
447- if ( Array . isArray ( answer . followupQuestions ) ) followups = answer . followupQuestions ;
448- const text = streamAnswerText ( answer . answer ) ;
449- if ( text !== undefined ) {
450- if ( text . length >= printedAnswerLen ) out ( text . slice ( printedAnswerLen ) ) ;
451- else out ( `\n${ text } ` ) ; // shrank unexpectedly: reprint whole
452- printedAnswerLen = text . length ;
453- }
520+ if ( typeof event . jsonChunk === 'string' ) {
521+ sink . out ( event . jsonChunk ) ;
454522 return ;
455523 }
456- // Agent-response stream (AIStreamResponse): a status line per event,
457- // except the JSON-object chunks which are raw text to concatenate.
458524 if ( typeof event . type === 'string' ) {
459- if ( typeof event . jsonChunk === 'string' ) {
460- out ( event . jsonChunk ) ;
461- return ;
462- }
463- line ( streamStatusLine ( event ) ) ;
525+ sink . line ( streamStatusLine ( event ) ) ;
464526 return ;
465527 }
466- line ( formatPretty ( event ) ) ;
467- } ,
468- end ( ) : void {
469- if ( midLine ) {
470- process . stdout . write ( '\n' ) ;
471- midLine = false ;
472- }
473- if ( sources && sources . length > 0 ) {
474- line ( '' ) ;
475- line ( 'Sources:' ) ;
476- for ( const s of sources ) line ( ` - ${ formatStreamSource ( s ) } ` ) ;
477- }
478- if ( followups && followups . length > 0 ) {
479- line ( '' ) ;
480- line ( 'Follow-up questions:' ) ;
481- for ( const q of followups ) line ( ` - ${ formatScalar ( q ) } ` ) ;
482- }
528+ sink . line ( formatPretty ( event ) ) ;
483529 } ,
484530 } ;
485531}
0 commit comments