22 * CAIPE interactive chat REPL built with Ink 5 + React 18.
33 *
44 * Architecture:
5- * - ALL visible text (user prompts, streamed chunks, completed responses)
6- * goes into Ink's <Static> — rendered once, never redrawn.
7- * - The "dynamic" area (redrawn on state changes) contains ONLY the
8- * input bar + status footer — typically 3-4 lines, no flashing.
9- * - Markdown is rendered incrementally: each streamed chunk is passed
10- * through renderMarkdown() once when flushed, then frozen in <Static>.
5+ * - Completed messages (user prompts, rendered assistant responses)
6+ * go into Ink's <Static> — rendered once, never redrawn.
7+ * - During streaming, raw text accumulates in the "dynamic" area
8+ * (redrawn on state changes) so the user sees tokens as they arrive.
9+ * - When streaming finishes, the full response is rendered through
10+ * renderMarkdown() (tables, code blocks, styled headings) and pushed
11+ * as one <Static> item — no flashing, proper formatting.
1112 */
1213
1314import { Box , Static , Text , useApp , useInput } from "ink" ;
@@ -464,6 +465,7 @@ export function Repl({
464465 const [ statusText , setStatusText ] = useState < string | null > ( null ) ;
465466 const [ pickerIndex , setPickerIndex ] = useState ( 0 ) ;
466467 const [ activeToolName , setActiveToolName ] = useState < string | null > ( null ) ;
468+ const [ streamingText , setStreamingText ] = useState ( "" ) ;
467469 const tokenCountRef = useRef ( 0 ) ;
468470 const streamStartRef = useRef ( 0 ) ;
469471 const ctrlDCountRef = useRef ( 0 ) ;
@@ -498,23 +500,17 @@ export function Repl({
498500 [ pushStatic ] ,
499501 ) ;
500502
501- const pushChunk = useCallback (
502- ( text : string ) => {
503- pushStatic ( { kind : "chunk" , text } ) ;
504- } ,
505- [ pushStatic ] ,
506- ) ;
507-
508503 const _pushTool = useCallback (
509504 ( name : string ) => {
510505 pushStatic ( { kind : "tool" , name } ) ;
511506 } ,
512507 [ pushStatic ] ,
513508 ) ;
514509
515- // ── Flush buffered streaming tokens into Static as complete lines ──
516- // Partial lines are held in lineBufferRef until a newline arrives,
517- // so words never break mid-line across Static items.
510+ // ── Flush buffered streaming tokens ──
511+ // Tokens accumulate in accumulatedRef and are displayed in the dynamic
512+ // area via streamingText state. When streaming ends, the full text is
513+ // rendered through renderMarkdown() and pushed as one Static item.
518514 const flushTokens = useCallback ( ( ) => {
519515 const text = pendingTokensRef . current ;
520516 const count = pendingTokenCountRef . current ;
@@ -524,24 +520,23 @@ export function Repl({
524520 tokenCountRef . current += count ;
525521 accumulatedRef . current += text ;
526522 setStreamTokenCount ( ( prev ) => prev + count ) ;
523+ setStreamingText ( accumulatedRef . current ) ;
524+ } , [ ] ) ;
527525
528- lineBufferRef . current += text ;
529- const lastNl = lineBufferRef . current . lastIndexOf ( "\n" ) ;
530- if ( lastNl !== - 1 ) {
531- // Push complete lines to Static — the remainder stays in the buffer
532- const completeLines = lineBufferRef . current . slice ( 0 , lastNl + 1 ) ;
533- lineBufferRef . current = lineBufferRef . current . slice ( lastNl + 1 ) ;
534- pushChunk ( completeLines ) ;
535- }
536- } , [ pushChunk ] ) ;
537-
538- // Flush whatever remains in the line buffer (called when streaming ends)
526+ // Flush whatever remains in pending tokens (called when streaming ends)
539527 const flushLineBuffer = useCallback ( ( ) => {
540- if ( lineBufferRef . current ) {
541- pushChunk ( lineBufferRef . current ) ;
542- lineBufferRef . current = "" ;
528+ // Any pending tokens not yet flushed
529+ const text = pendingTokensRef . current ;
530+ if ( text ) {
531+ pendingTokensRef . current = "" ;
532+ const count = pendingTokenCountRef . current ;
533+ pendingTokenCountRef . current = 0 ;
534+ tokenCountRef . current += count ;
535+ accumulatedRef . current += text ;
536+ setStreamTokenCount ( ( prev ) => prev + count ) ;
543537 }
544- } , [ pushChunk ] ) ;
538+ lineBufferRef . current = "" ;
539+ } , [ ] ) ;
545540
546541 // ── Slash picker ──
547542 const filteredCommands = useMemo < SlashCommand [ ] > ( ( ) => {
@@ -751,11 +746,11 @@ export function Repl({
751746 pushUser ( text ) ;
752747 tokenCountRef . current += Math . ceil ( prompt . length / 4 ) ;
753748
754- // Start streaming — push the ⏺ prefix as the first chunk
749+ // Start streaming — show response in dynamic area until complete
755750 accumulatedRef . current = "" ;
756751 setStreamTokenCount ( 0 ) ;
752+ setStreamingText ( "" ) ;
757753 setStreaming ( true ) ;
758- pushStatic ( { kind : "chunk" , text : "" } ) ; // spacer
759754
760755 try {
761756 const gen = adapter . connect ( {
@@ -787,32 +782,35 @@ export function Repl({
787782 }
788783 }
789784
790- // Flush remaining buffered tokens + partial line
785+ // Flush remaining buffered tokens
791786 if ( flushTimerRef . current ) {
792787 clearTimeout ( flushTimerRef . current ) ;
793788 flushTimerRef . current = null ;
794789 }
795790 flushTokens ( ) ;
796791 flushLineBuffer ( ) ;
797792
798- // If pipe was requested, filter the accumulated response
793+ // Clear streaming display and push markdown-rendered result to Static
794+ setStreamingText ( "" ) ;
795+
799796 let finalContent = accumulatedRef . current ;
800797 if ( parsed . pipeCmd && finalContent ) {
801798 setStatusText ( `Piping through: ${ parsed . pipeCmd } ` ) ;
802799 finalContent = await pipeThrough ( finalContent , parsed . pipeCmd ) ;
803- // Show piped result as a completed assistant message
804- pushAssistant ( finalContent ) ;
805800 }
806801
807- // Store full response in history for context
808- historyRef . current . push ( { role : "assistant" , content : accumulatedRef . current } ) ;
802+ // Render and freeze the complete response in Static
803+ if ( finalContent ) {
804+ pushAssistant ( finalContent ) ;
805+ }
809806 } catch ( err ) {
810807 const msg = err instanceof Error ? err . message : String ( err ) ;
811808 pushAssistant ( `[ERROR] ${ msg } ` ) ;
812809 } finally {
813810 pendingTokensRef . current = "" ;
814811 pendingTokenCountRef . current = 0 ;
815812 lineBufferRef . current = "" ;
813+ setStreamingText ( "" ) ;
816814 setStreaming ( false ) ;
817815 setActiveToolName ( null ) ;
818816 setStatusText ( null ) ;
@@ -827,7 +825,6 @@ export function Repl({
827825 flushLineBuffer ,
828826 pushUser ,
829827 pushAssistant ,
830- pushStatic ,
831828 ] ,
832829 ) ;
833830
@@ -917,13 +914,24 @@ export function Repl({
917914 } }
918915 </ Static >
919916
920- { /* Dynamic area: ONLY the input + status — tiny, no flashing */ }
917+ { /* Dynamic area: streaming text + input + status */ }
921918 < Box flexDirection = "column" flexGrow = { 1 } paddingY = { 0 } >
922- { staticItems . length === 0 && ! streaming && (
919+ { streaming && streamingText ? (
920+ < Box paddingX = { 1 } flexDirection = "column" >
921+ < Box >
922+ < Text color = "blue" > { "⏺ " } </ Text >
923+ </ Box >
924+ < Text > { streamingText } </ Text >
925+ </ Box >
926+ ) : staticItems . length === 0 && ! streaming ? (
923927 < Box paddingX = { 1 } >
924928 < Text dimColor > Type a message or / for commands.</ Text >
925929 </ Box >
926- ) }
930+ ) : streaming ? (
931+ < Box paddingX = { 1 } >
932+ < Text color = "blue" > { "⏺ " } </ Text >
933+ </ Box >
934+ ) : null }
927935 </ Box >
928936
929937 { showPicker && (
0 commit comments